From dc5bdfa7f0ddc14b2f3adf88020a99153bb79b2e Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 4 Sep 2026 17:22:28 +0200 Subject: [PATCH 1/3] feat(lighting): add the @codexo/exojs-lighting extension package Forward normal-mapped point lighting for sprites, shaded inside the sprite fragment stage so a lit scene costs no extra pass and no extra draw call. PointLight is plain mutable world-space data. LightingSystem collects lights and packs them, together with the active count and the ambient term, into one rgba32f DataTexture per frame - a header column plus one column per light. That is why the light count is a shader loop bound instead of a compiled-in constant: core user uniforms are one vec4 per name, so a uniform array would have capped the scene at a handful of lights and needed a recompile to change. LitSpriteMaterial samples a tangent-space normal map next to the albedo and rotates the normal by the instance's local-to-world basis, so spinning and mirrored sprites keep their bumps facing the right way. One normal map per material (= per atlas) is the v1 contract; a second atlas takes a second material and breaks the batch at that boundary. No deferred path. Also stop type-aware lint rules from throwing on every package's rolldown.config.ts: those files belong to no package's TypeScript program, so the ProjectService has no types to serve for them. Claude-Session: https://claude.ai/code/session_01NSrXpH1WyqP7udpWWhHz1i --- .codecov.yml | 4 + .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 1 + eslint.config.ts | 36 ++++ package.json | 8 +- packages/exojs-lighting/LICENSE | 21 ++ packages/exojs-lighting/README.md | 117 +++++++++++ packages/exojs-lighting/package.json | 46 +++++ packages/exojs-lighting/rolldown.config.ts | 10 + packages/exojs-lighting/src/LightingSystem.ts | 184 ++++++++++++++++++ .../exojs-lighting/src/LitSpriteMaterial.ts | 93 +++++++++ packages/exojs-lighting/src/PointLight.ts | 61 ++++++ packages/exojs-lighting/src/index.ts | 3 + packages/exojs-lighting/src/public.ts | 10 + .../src/shaders/lit-sprite.frag | 44 +++++ .../src/shaders/lit-sprite.vert | 9 + .../src/shaders/lit-sprite.wgsl | 38 ++++ packages/exojs-lighting/src/typings.d.ts | 14 ++ .../test/LightingSystem.test.ts | 127 ++++++++++++ .../test/LitSpriteMaterial.test.ts | 64 ++++++ packages/exojs-lighting/tsconfig.build.json | 12 ++ packages/exojs-lighting/tsconfig.json | 11 ++ packages/exojs-lighting/tsconfig.test.json | 7 + pnpm-lock.yaml | 9 + pnpm-workspace.yaml | 1 + scripts/ci/lanes.ts | 2 +- scripts/ci/select-lanes.ts | 1 + scripts/exo-full.entry.ts | 3 + scripts/release/external-consumers.ts | 8 + scripts/release/lockstep-packages.ts | 1 + site/public/preview.html | 4 + site/scripts/build-api.ts | 7 + site/scripts/sync-exo-vendor.ts | 1 + site/src/components/EditorCode.tsx | 1 + site/src/lib/api-reference.ts | 5 + tsconfig.examples.json | 1 + tsconfig.guides.json | 3 +- vitest.config.ts | 10 + 38 files changed, 972 insertions(+), 7 deletions(-) create mode 100644 packages/exojs-lighting/LICENSE create mode 100644 packages/exojs-lighting/README.md create mode 100644 packages/exojs-lighting/package.json create mode 100644 packages/exojs-lighting/rolldown.config.ts create mode 100644 packages/exojs-lighting/src/LightingSystem.ts create mode 100644 packages/exojs-lighting/src/LitSpriteMaterial.ts create mode 100644 packages/exojs-lighting/src/PointLight.ts create mode 100644 packages/exojs-lighting/src/index.ts create mode 100644 packages/exojs-lighting/src/public.ts create mode 100644 packages/exojs-lighting/src/shaders/lit-sprite.frag create mode 100644 packages/exojs-lighting/src/shaders/lit-sprite.vert create mode 100644 packages/exojs-lighting/src/shaders/lit-sprite.wgsl create mode 100644 packages/exojs-lighting/src/typings.d.ts create mode 100644 packages/exojs-lighting/test/LightingSystem.test.ts create mode 100644 packages/exojs-lighting/test/LitSpriteMaterial.test.ts create mode 100644 packages/exojs-lighting/tsconfig.build.json create mode 100644 packages/exojs-lighting/tsconfig.json create mode 100644 packages/exojs-lighting/tsconfig.test.json diff --git a/.codecov.yml b/.codecov.yml index e4c77b563..2dda7dd0f 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -107,6 +107,10 @@ component_management: name: tilemap-physics paths: - packages/exojs-tilemap-physics/src/** + - component_id: lighting + name: lighting + paths: + - packages/exojs-lighting/src/** - component_id: audio-fx name: audio-fx paths: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3289c5095..d0e38510a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -174,7 +174,7 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} run: >- pnpm --filter "@codexo/exojs-particles" --filter "@codexo/exojs-tilemap" --filter "@codexo/exojs-tiled" - --filter "@codexo/exojs-physics" --filter "@codexo/exojs-tilemap-physics" --filter "@codexo/exojs-audio-fx" + --filter "@codexo/exojs-physics" --filter "@codexo/exojs-tilemap-physics" --filter "@codexo/exojs-lighting" --filter "@codexo/exojs-audio-fx" --filter "@codexo/exojs-aseprite" --filter "@codexo/exojs-ldtk" --filter "@codexo/exojs-react" build - name: Verify production stripping against the built dist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7e5b09e3..189244deb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,6 +93,7 @@ jobs: pnpm --filter @codexo/exojs-tiled build pnpm --filter @codexo/exojs-physics build pnpm --filter @codexo/exojs-tilemap-physics build + pnpm --filter @codexo/exojs-lighting build pnpm --filter @codexo/exojs-audio-fx build pnpm --filter @codexo/exojs-aseprite build pnpm --filter @codexo/exojs-ldtk build diff --git a/eslint.config.ts b/eslint.config.ts index d1e7966f7..df10670bd 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -750,6 +750,42 @@ export default defineConfig([ }, }, + // The light packer walks the registered lights by computed index inside a + // loop bounded by the count it just derived from their length, so `arr[i]!` + // says what the reader already knows and a per-frame `for...of` iterator is + // exactly the allocation this path exists to avoid. + { + files: ['packages/exojs-lighting/src/LightingSystem.ts'], + rules: { + '@typescript-eslint/no-non-null-assertion': 'off', + }, + }, + + // A package's Rolldown config belongs to no package's TypeScript program: + // each package tsconfig covers `src/**` only, so the ProjectService has no + // type information to serve for these files and every type-aware rule throws + // on them. The syntactic and stylistic policy still applies. + { + files: ['packages/*/rolldown.config.ts'], + ...tseslint.configs.disableTypeChecked, + }, + { + files: ['packages/*/rolldown.config.ts'], + languageOptions: { + parserOptions: { projectService: false, project: null }, + }, + }, + + // Material binding names are GLSL/WGSL identifiers, which the shader sources + // declare in snake_case with the engine's `u_` prefix. The object literal has + // to spell them exactly as the shader does. + { + files: ['packages/exojs-lighting/src/LitSpriteMaterial.ts'], + rules: { + '@typescript-eslint/naming-convention': 'off', + }, + }, + // `Map.forEach` is the allocation-free way to walk a Map: `for...of` builds a // fresh iterator on every step, which these two per-frame paths cannot // afford. Deleting the current entry mid-`forEach` is well-defined and both diff --git a/package.json b/package.json index dedd58898..b90d5bb05 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "verify:exports": "tsx ./scripts/verify-exports.ts", "verify:declaration-imports": "tsx ./scripts/verify-declaration-imports.ts", "verify:package-policy": "tsx ./scripts/verify-package-policy.ts", - "verify:publint": "pnpm dlx publint@0.3.21 --strict . && pnpm --filter \"@codexo/exojs-build\" --filter \"@codexo/exojs-particles\" --filter \"@codexo/exojs-tilemap\" --filter \"@codexo/exojs-tiled\" --filter \"@codexo/exojs-physics\" --filter \"@codexo/exojs-tilemap-physics\" --filter \"@codexo/exojs-audio-fx\" exec pnpm dlx publint@0.3.21 --strict .", + "verify:publint": "pnpm dlx publint@0.3.21 --strict . && pnpm --filter \"@codexo/exojs-build\" --filter \"@codexo/exojs-particles\" --filter \"@codexo/exojs-tilemap\" --filter \"@codexo/exojs-tiled\" --filter \"@codexo/exojs-physics\" --filter \"@codexo/exojs-tilemap-physics\" --filter \"@codexo/exojs-lighting\" --filter \"@codexo/exojs-audio-fx\" exec pnpm dlx publint@0.3.21 --strict .", "verify:package": "pnpm build && pnpm verify:exports && pnpm verify:declaration-imports && pnpm pack", "verify:lockstep": "tsx ./scripts/verify-lockstep-versions.ts", "verify:release-matrix": "tsx ./scripts/verify-release-matrix.ts", @@ -115,7 +115,7 @@ "typecheck:guides:update-baseline": "tsx scripts/extract-guide-snippets.ts --update-baseline", "typecheck:guides:no-check": "tsx scripts/check-guide-no-check-reasons.ts", "typecheck:guides:no-check:update-baseline": "tsx scripts/check-guide-no-check-reasons.ts --update-baseline", - "typecheck:packages": "pnpm --filter \"@codexo/exojs-build\" --filter \"@codexo/exojs-particles\" --filter \"@codexo/exojs-tilemap\" --filter \"@codexo/exojs-tiled\" --filter \"@codexo/exojs-physics\" --filter \"@codexo/exojs-tilemap-physics\" --filter \"@codexo/exojs-audio-fx\" --filter \"@codexo/exojs-aseprite\" --filter \"@codexo/exojs-ldtk\" --filter \"@codexo/exojs-react\" typecheck", + "typecheck:packages": "pnpm --filter \"@codexo/exojs-build\" --filter \"@codexo/exojs-particles\" --filter \"@codexo/exojs-tilemap\" --filter \"@codexo/exojs-tiled\" --filter \"@codexo/exojs-physics\" --filter \"@codexo/exojs-tilemap-physics\" --filter \"@codexo/exojs-lighting\" --filter \"@codexo/exojs-audio-fx\" --filter \"@codexo/exojs-aseprite\" --filter \"@codexo/exojs-ldtk\" --filter \"@codexo/exojs-react\" typecheck", "typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json", "typecheck:site": "pnpm --filter @codexo/exojs-examples check-ts", "typecheck:site-scripts": "pnpm --filter @codexo/exojs-examples check-ts:scripts", @@ -140,9 +140,9 @@ "lint:shaders": "tsx scripts/check-shader-sources.ts", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "vitest run --project=exojs --project=exojs-build --project=exojs-particles --project=exojs-tilemap --project=exojs-tiled --project=exojs-physics --project=exojs-tilemap-physics --project=exojs-audio-fx --project=exojs-aseprite --project=exojs-ldtk --project=exojs-react --project=rendering-perf --project=rendering-alloc", + "test": "vitest run --project=exojs --project=exojs-build --project=exojs-particles --project=exojs-tilemap --project=exojs-tiled --project=exojs-physics --project=exojs-tilemap-physics --project=exojs-lighting --project=exojs-audio-fx --project=exojs-aseprite --project=exojs-ldtk --project=exojs-react --project=rendering-perf --project=rendering-alloc", "test:core": "vitest run --project=exojs", - "test:coverage": "vitest run --coverage --project=exojs --project=exojs-build --project=exojs-particles --project=exojs-tilemap --project=exojs-tiled --project=exojs-physics --project=exojs-tilemap-physics --project=exojs-audio-fx --project=exojs-aseprite --project=exojs-ldtk --project=exojs-react --project=rendering-perf", + "test:coverage": "vitest run --coverage --project=exojs --project=exojs-build --project=exojs-particles --project=exojs-tilemap --project=exojs-tiled --project=exojs-physics --project=exojs-tilemap-physics --project=exojs-lighting --project=exojs-audio-fx --project=exojs-aseprite --project=exojs-ldtk --project=exojs-react --project=rendering-perf", "test:alloc": "vitest run --project=rendering-alloc", "test:watch": "vitest --project=exojs", "test:production-stripping": "vitest run --project=exojs test/build-defines/production-stripping.test.ts", diff --git a/packages/exojs-lighting/LICENSE b/packages/exojs-lighting/LICENSE new file mode 100644 index 000000000..dfb7cd04a --- /dev/null +++ b/packages/exojs-lighting/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Codexo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/exojs-lighting/README.md b/packages/exojs-lighting/README.md new file mode 100644 index 000000000..b75044b89 --- /dev/null +++ b/packages/exojs-lighting/README.md @@ -0,0 +1,117 @@ +# @codexo/exojs-lighting + +Official ExoJS extension for forward, normal-mapped 2D lighting. Point lights shade sprites +inside the sprite fragment stage, so a lit scene costs no extra render pass and no extra draw +call: sprites sharing one lit material stay in one batch. + +## Installation + +```sh +npm install @codexo/exojs @codexo/exojs-lighting +``` + +`@codexo/exojs` is a peer dependency. This package has no other runtime dependencies. + +## What this package provides + +- `PointLight` - plain mutable world-space light data (`x`, `y`, `radius`, `color`, + `intensity`, `height`). Not a scene node. +- `LightingSystem` - collects lights, packs them into one `rgba32f` data texture per frame, and + carries the ambient term with them. Registers on a `SystemRegistry` like any other system. +- `LitSpriteMaterial` - a `SpriteMaterial` (GLSL + WGSL) that samples a tangent-space normal map + next to the sprite's albedo and shades it against the system's lights. + +## Usage + +```ts +import { Color, Scene, type Seconds, Sprite } from '@codexo/exojs'; +import { LightingSystem, LitSpriteMaterial, PointLight } from '@codexo/exojs-lighting'; + +class LitScene extends Scene { + private lighting = new LightingSystem({ ambient: new Color(30, 30, 45) }); + private torch = new PointLight({ x: 400, y: 300, radius: 320, color: new Color(255, 180, 120) }); + private elapsed = 0; + + override init(): void { + this.lighting.add(this.torch); + // 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); + + const sprite = new Sprite(albedoTexture); + + sprite.material = new LitSpriteMaterial({ lighting: this.lighting, normalMap: normalTexture }); + this.root.addChild(sprite); + } + + override update(delta: Seconds): void { + this.elapsed += delta; + this.torch.setPosition(400 + Math.cos(this.elapsed) * 200, 300); + } +} +``` + +## How the lights reach the shader + +`LightingSystem` owns a single `rgba32f` `DataTexture`, `maxLights + 1` texels wide and two 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 | +| ------- | ----------------------------- | ----------------------------------- | +| `0` | `(activeLightCount, 0, 0, 0)` | `(ambientR, ambientG, ambientB, 0)` | +| `i + 1` | `(x, y, radius, intensity)` | `(r, g, b, height)` | + +Colour channels are normalized to `0..1`. 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 + +A normal map is a **material** binding, not a per-sprite one: every sprite drawn with a given +`LitSpriteMaterial` 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` right and `+y` down the texture. 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 `LitSpriteMaterial`, which breaks the batch at the +material boundary. Both materials can shade against the same `LightingSystem`. + +## Capabilities + +| Capability | Status | +| ------------------------------------------- | -------------------------------------------- | +| Forward point lights on sprites | yes, WebGL2 and WebGPU | +| Lights per material | `maxLights` (default 64), one shader loop | +| Ambient term | yes, carried in the light texture | +| Normal maps | one per material (= per atlas) | +| Rotation / flip aware normals | yes, via the instance's local-to-world basis | +| Extra render passes or draw calls | none | +| Shadows, occlusion, light volumes | no | +| Deferred (G-buffer) path | no | +| Lit meshes, text, particles, tilemap layers | no - `SpriteMaterial` targets sprites | + +## 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. + +## Core compatibility + +| `@codexo/exojs-lighting` | `@codexo/exojs` | +| ------------------------ | --------------- | +| 0.16.x | 0.16.x | + +## Links + +- [API reference](https://exojs.dev/api/exojs-lighting) + +## License + +MIT © Codexo diff --git a/packages/exojs-lighting/package.json b/packages/exojs-lighting/package.json new file mode 100644 index 000000000..5398e85da --- /dev/null +++ b/packages/exojs-lighting/package.json @@ -0,0 +1,46 @@ +{ + "name": "@codexo/exojs-lighting", + "version": "0.16.1", + "description": "Forward 2D normal-mapped point lighting for ExoJS sprites.", + "repository": { + "type": "git", + "url": "git+https://github.com/Exoridus/ExoJS.git", + "directory": "packages/exojs-lighting" + }, + "type": "module", + "sideEffects": false, + "main": "./dist/esm/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js", + "default": "./dist/esm/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/esm/", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsx ../../scripts/build-extension.ts", + "build:dev": "tsx ../../scripts/build-extension.ts --dev", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", + "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", + "test": "vitest run --root ../.. --project=exojs-lighting" + }, + "peerDependencies": { + "@codexo/exojs": "0.16.x" + }, + "devDependencies": { + "@codexo/exojs": "workspace:*", + "@codexo/exojs-config": "workspace:*" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/exojs-lighting/rolldown.config.ts b/packages/exojs-lighting/rolldown.config.ts new file mode 100644 index 000000000..ae940b202 --- /dev/null +++ b/packages/exojs-lighting/rolldown.config.ts @@ -0,0 +1,10 @@ +import { createExtensionBuildOptions } from '@codexo/exojs-config/rolldown'; + +// @codexo/exojs-lighting is a library package: a single side-effect-free entry. +// No package-internal `#` imports (all same-directory `./`), so no source +// condition / node-resolve is needed; Core's `#` resolves to its dist. +export default createExtensionBuildOptions({ + root: import.meta.dirname, + sourceCondition: null, + inputs: ['src/index.ts'], +}); diff --git a/packages/exojs-lighting/src/LightingSystem.ts b/packages/exojs-lighting/src/LightingSystem.ts new file mode 100644 index 000000000..fe9039adb --- /dev/null +++ b/packages/exojs-lighting/src/LightingSystem.ts @@ -0,0 +1,184 @@ +import { Color, DataTexture, TextureFormat } from '@codexo/exojs'; + +import type { PointLight } from './PointLight'; + +/** Construction options for {@link LightingSystem}. */ +export interface LightingSystemOptions { + /** + * Lights the light texture is sized for. Fixed at construction; lights + * registered beyond it are ignored by {@link LightingSystem.commit} until a + * registered one is removed. Defaults to `64`. + */ + readonly maxLights?: number; + /** + * Baseline colour every lit fragment receives regardless of any light, as a + * multiplier on the albedo (`255` per channel means "unlit areas keep their + * full albedo"). Stored by reference and re-read on every commit. + * Defaults to a dim neutral blue-grey. + */ + readonly ambient?: Color; +} + +/** Column 0 of both rows is the header; light `i` occupies column `i + 1`. */ +const headerColumns = 1; +const rows = 2; +const channels = 4; + +/** + * Collects {@link PointLight}s and publishes them to shaders as a single + * floating-point data texture. + * + * The texture is `rgba32f`, `maxLights + 1` texels wide and 2 rows tall: + * + * | column | row 0 | row 1 | + * | -------- | ------------------------------ | ---------------------------------- | + * | `0` | `(activeLightCount, 0, 0, 0)` | `(ambientR, ambientG, ambientB, 0)` | + * | `i + 1` | `(x, y, radius, intensity)` | `(r, g, b, height)` | + * + * Colour channels are normalized to `0..1`. Because the light count and the + * ambient term travel in the texture as well, a material that samples it needs + * no per-frame uniform update, and any number of materials can share one + * system. + * + * # Lifecycle + * + * `LightingSystem` is a `System`: register it with the registry that + * ticks *after* the code that moves the lights, so the packed texture describes + * the frame that is about to be drawn. + * + * ```ts + * const lighting = new LightingSystem({ ambient: new Color(30, 30, 45) }); + * + * scene.systems.add(lighting); // scene systems update after Scene.update() + * ``` + * + * `app.systems` runs its update phase *before* the active scene's, so a system + * registered there sees lights that scene code has not moved yet. Register on + * the scene, or call {@link commit} yourself at the point that suits the app. + * + * The system owns its light texture and destroys it in {@link destroy}; the + * lights themselves are plain data and are not owned. + */ +export class LightingSystem { + /** Baseline colour applied to every lit fragment. Mutable; re-read on each commit. */ + public ambient: Color; + + /** Lights the texture is sized for. */ + public readonly maxLights: number; + + private readonly _lights: PointLight[] = []; + private readonly _texture: DataTexture; + private _activeCount = 0; + + public constructor(options: LightingSystemOptions = {}) { + this.maxLights = options.maxLights ?? 64; + this.ambient = options.ambient ?? new Color(28, 28, 38); + this._texture = new DataTexture({ + width: this.maxLights + headerColumns, + height: rows, + format: TextureFormat.Rgba32F, + }); + + this.commit(); + } + + /** + * The packed light texture, to be bound as a material texture. Its identity + * is stable for the system's lifetime. + */ + public get lightTexture(): DataTexture { + return this._texture; + } + + /** Currently registered lights, in registration order. */ + public get lights(): readonly PointLight[] { + return this._lights; + } + + /** Lights the last {@link commit} actually published - `min(lights.length, maxLights)`. */ + public get activeLightCount(): number { + return this._activeCount; + } + + /** Register a light. Registering the same light twice shades it twice. */ + public add(light: PointLight): this { + this._lights.push(light); + + return this; + } + + /** Unregister a light. Returns `false` when it was not registered. */ + public remove(light: PointLight): boolean { + const index = this._lights.indexOf(light); + + if (index === -1) { + return false; + } + + this._lights.splice(index, 1); + + return true; + } + + /** Unregister every light. */ + public clear(): this { + this._lights.length = 0; + + return this; + } + + /** + * Pack the registered lights and the ambient term into the light texture and + * mark it for upload. + * + * Lights are plain mutable data, so there is nothing to observe: every call + * rewrites the whole header and light range unconditionally. Surplus lights + * beyond {@link maxLights} are skipped. + */ + public commit(): this { + const buffer = this._texture.buffer; + const count = Math.min(this._lights.length, this.maxLights); + const secondRow = this._texture.width * channels; + + buffer[0] = count; + buffer[1] = 0; + buffer[2] = 0; + buffer[3] = 0; + buffer[secondRow] = this.ambient.r / 255; + buffer[secondRow + 1] = this.ambient.g / 255; + buffer[secondRow + 2] = this.ambient.b / 255; + buffer[secondRow + 3] = 0; + + for (let index = 0; index < count; index++) { + const light = this._lights[index]!; + const offset = (index + headerColumns) * channels; + + buffer[offset] = light.x; + buffer[offset + 1] = light.y; + buffer[offset + 2] = light.radius; + buffer[offset + 3] = light.intensity; + + buffer[secondRow + offset] = light.color.r / 255; + buffer[secondRow + offset + 1] = light.color.g / 255; + buffer[secondRow + offset + 2] = light.color.b / 255; + buffer[secondRow + offset + 3] = light.height; + } + + this._activeCount = count; + this._texture.commit(); + + return this; + } + + /** `System` update phase - equivalent to {@link commit}. */ + public update(): void { + this.commit(); + } + + /** Release the light texture. The registered lights are untouched. */ + public destroy(): void { + this._lights.length = 0; + this._activeCount = 0; + this._texture.destroy(); + } +} diff --git a/packages/exojs-lighting/src/LitSpriteMaterial.ts b/packages/exojs-lighting/src/LitSpriteMaterial.ts new file mode 100644 index 000000000..012aa8663 --- /dev/null +++ b/packages/exojs-lighting/src/LitSpriteMaterial.ts @@ -0,0 +1,93 @@ +import type { BlendModes, SamplerOptions, Texture } from '@codexo/exojs'; +import { ShaderSource, SpriteMaterial } from '@codexo/exojs'; + +import type { LightingSystem } from './LightingSystem'; +import fragmentGlsl from './shaders/lit-sprite.frag'; +import vertexGlsl from './shaders/lit-sprite.vert'; +import fragmentWgsl from './shaders/lit-sprite.wgsl'; + +/** + * The one shader pair behind every `LitSpriteMaterial`. Renderers key their + * compiled program and pipeline caches on `ShaderSource` identity, so N lit + * materials cost one compile per backend rather than N. + */ +const litSpriteShader = new ShaderSource({ + glsl: { vertex: vertexGlsl, fragment: fragmentGlsl }, + wgsl: fragmentWgsl, +}); + +/** Construction options for {@link LitSpriteMaterial}. */ +export interface LitSpriteMaterialOptions { + /** System whose light texture this material shades against. Not owned. */ + readonly lighting: LightingSystem; + /** + * Tangent-space normal map, sampled with the sprite's own UVs: it must be + * laid out exactly like the base atlas, frame for frame. `+x` points right + * and `+y` points down the texture, matching the sprite's local axes. + */ + readonly normalMap: Texture; + /** Compositing blend mode. Defaults to `BlendModes.Normal`. */ + readonly blendMode?: BlendModes; + /** Filter/wrap override for the base texture, or `null` to inherit it. Defaults to `null`. */ + readonly sampler?: SamplerOptions | null; +} + +/** + * A {@link SpriteMaterial} that shades sprites with a tangent-space normal map + * against the point lights of a {@link LightingSystem}. + * + * Lighting happens in the sprite's own fragment stage, so a lit sprite costs no + * extra pass and no extra draw call: every sprite sharing this material and a + * base-texture slot stays in one batch. The shaded result is + * `albedo * (ambient + sum over lights)`, with each light falling off + * quadratically to nothing at its radius. + * + * # One normal map per material + * + * The normal map is a material binding, not a per-sprite one, so all sprites + * drawn with a given `LitSpriteMaterial` must share its layout - in practice + * one material per atlas. Sprites from a second atlas need a second material, + * which breaks the batch at the material boundary. Both materials can shade + * against the same `LightingSystem`. + * + * # Ownership + * + * The material owns neither the lighting system nor the textures. + * {@link SpriteMaterial.destroy} releases only the GPU resources cached against + * this material. + * + * ```ts + * const lighting = new LightingSystem(); + * const material = new LitSpriteMaterial({ lighting, normalMap }); + * + * scene.systems.add(lighting); + * lighting.add(new PointLight({ x: 400, y: 300, radius: 320 })); + * sprite.material = material; + * ``` + */ +export class LitSpriteMaterial extends SpriteMaterial { + /** The system this material shades against. */ + public readonly lighting: LightingSystem; + + public constructor(options: LitSpriteMaterialOptions) { + super({ + shader: litSpriteShader, + // Declaration order is the group(2) binding order on WebGPU: normal map at + // bindings 1/2, light texture at 3/4, matching `lit-sprite.wgsl`. + textures: { u_normalMap: options.normalMap, u_lights: options.lighting.lightTexture }, + ...(options.blendMode !== undefined ? { blendMode: options.blendMode } : {}), + ...(options.sampler !== undefined ? { sampler: options.sampler } : {}), + }); + + this.lighting = options.lighting; + } + + /** The bound normal map. Assigning a replacement takes effect on the next draw. */ + public get normalMap(): Texture { + return this.textures.u_normalMap as Texture; + } + + public set normalMap(texture: Texture) { + this.setTexture('u_normalMap', texture); + } +} diff --git a/packages/exojs-lighting/src/PointLight.ts b/packages/exojs-lighting/src/PointLight.ts new file mode 100644 index 000000000..bda6b00e1 --- /dev/null +++ b/packages/exojs-lighting/src/PointLight.ts @@ -0,0 +1,61 @@ +import { Color } from '@codexo/exojs'; + +/** Construction options for {@link PointLight}. Every field is also mutable afterwards. */ +export interface PointLightOptions { + /** World-space x, in pixels. */ + readonly x?: number; + /** World-space y, in pixels. */ + readonly y?: number; + /** Distance in pixels at which the light contributes nothing. Defaults to `256`. */ + readonly radius?: number; + /** + * Light colour. The instance is stored by reference, so mutating it after + * construction is picked up on the next {@link LightingSystem.commit}. + * Defaults to opaque white; alpha is ignored. + */ + readonly color?: Color; + /** Linear brightness multiplier. Defaults to `1`. */ + readonly intensity?: number; + /** + * Height above the sprite plane, in pixels. Drives how grazing the light + * direction is: small values rake across the surface and exaggerate the + * normal map, large values flatten it out. Defaults to `64`. + */ + readonly height?: number; +} + +/** + * A world-space point light. + * + * Plain mutable data - no scene node, no transform, no parenting. Move one by + * assigning {@link x}/{@link y} (or through {@link setPosition}) and the change + * takes effect on the next {@link LightingSystem.commit}. A light contributes + * to shading only while it is registered with a {@link LightingSystem}. + */ +export class PointLight { + public x: number; + public y: number; + public radius: number; + public color: Color; + public intensity: number; + public height: number; + + public constructor(options: PointLightOptions = {}) { + this.x = options.x ?? 0; + this.y = options.y ?? 0; + this.radius = options.radius ?? 256; + // Not `Color.white`: that is a shared frozen-by-convention singleton, and a + // light whose colour is mutated would recolour every other default light. + this.color = options.color ?? new Color(255, 255, 255); + this.intensity = options.intensity ?? 1; + this.height = options.height ?? 64; + } + + /** Move the light, returning `this` for chaining. */ + public setPosition(x: number, y: number): this { + this.x = x; + this.y = y; + + return this; + } +} diff --git a/packages/exojs-lighting/src/index.ts b/packages/exojs-lighting/src/index.ts new file mode 100644 index 000000000..6dd0bbe42 --- /dev/null +++ b/packages/exojs-lighting/src/index.ts @@ -0,0 +1,3 @@ +// @codexo/exojs-lighting - side-effect-free root entry. + +export * from './public'; diff --git a/packages/exojs-lighting/src/public.ts b/packages/exojs-lighting/src/public.ts new file mode 100644 index 000000000..fcf53d1ba --- /dev/null +++ b/packages/exojs-lighting/src/public.ts @@ -0,0 +1,10 @@ +// Side-effect-free public API for @codexo/exojs-lighting. +// Importing this entry performs no registration: a LightingSystem is +// constructed directly and added to the system registry that should tick it. + +export type { LightingSystemOptions } from './LightingSystem'; +export { LightingSystem } from './LightingSystem'; +export type { LitSpriteMaterialOptions } from './LitSpriteMaterial'; +export { LitSpriteMaterial } from './LitSpriteMaterial'; +export type { PointLightOptions } from './PointLight'; +export { PointLight } from './PointLight'; diff --git a/packages/exojs-lighting/src/shaders/lit-sprite.frag b/packages/exojs-lighting/src/shaders/lit-sprite.frag new file mode 100644 index 000000000..47d29cd63 --- /dev/null +++ b/packages/exojs-lighting/src/shaders/lit-sprite.frag @@ -0,0 +1,44 @@ +#version 300 es +precision highp float; +precision highp int; + +// Forward point lighting for one sprite fragment. The engine splices its +// base-texture slot table and `sampleBase()` in below the precision block. +in vec2 v_texcoord; +in vec4 v_color; +in vec2 v_worldPosition; +flat in vec4 v_basis; + +uniform sampler2D u_normalMap; +// Light data is world-space positions and radii, so the sampler has to keep +// full float precision - the fragment-stage default for a sampler is lowp, +// which would quantise a light position to a few hundred distinct values. +uniform highp sampler2D u_lights; + +out vec4 fragColor; + +void main(void) { + vec4 base = sampleBase(v_textureSlot, v_texcoord); + + // Rotate the tangent-space normal by the instance's local-to-world basis so + // a spinning or mirrored sprite keeps its bumps facing the right way. + vec3 tangentNormal = texture(u_normalMap, v_texcoord).xyz * 2.0 - 1.0; + vec2 axisX = normalize(vec2(v_basis.x, v_basis.z)); + vec2 axisY = normalize(vec2(v_basis.y, v_basis.w)); + vec3 normal = normalize(vec3(axisX * tangentNormal.x + axisY * tangentNormal.y, tangentNormal.z)); + + int count = int(texelFetch(u_lights, ivec2(0, 0), 0).x); + vec3 lit = texelFetch(u_lights, ivec2(0, 1), 0).rgb; + + for (int index = 0; index < count; index++) { + vec4 light = texelFetch(u_lights, ivec2(index + 1, 0), 0); + vec4 tint = texelFetch(u_lights, ivec2(index + 1, 1), 0); + vec2 toLight = light.xy - v_worldPosition; + float falloff = clamp(1.0 - length(toLight) / light.z, 0.0, 1.0); + vec3 direction = normalize(vec3(toLight, tint.w)); + + lit += tint.rgb * (max(dot(normal, direction), 0.0) * falloff * falloff * light.w); + } + + fragColor = vec4(base.rgb * lit, base.a) * v_color; +} diff --git a/packages/exojs-lighting/src/shaders/lit-sprite.vert b/packages/exojs-lighting/src/shaders/lit-sprite.vert new file mode 100644 index 000000000..eddf6b6ff --- /dev/null +++ b/packages/exojs-lighting/src/shaders/lit-sprite.vert @@ -0,0 +1,9 @@ +#version 300 es +precision highp float; + +// Placeholder. The renderer owns the vertex stage of every sprite material and +// never compiles this source; `ShaderSource` requires a GLSL vertex string, so +// one has to exist. Nothing here reaches the GPU. +void main(void) { + gl_Position = vec4(0.0); +} diff --git a/packages/exojs-lighting/src/shaders/lit-sprite.wgsl b/packages/exojs-lighting/src/shaders/lit-sprite.wgsl new file mode 100644 index 000000000..8ba1b87cf --- /dev/null +++ b/packages/exojs-lighting/src/shaders/lit-sprite.wgsl @@ -0,0 +1,38 @@ +// Forward point lighting for one sprite fragment. The engine prepends its +// sprite-material prologue, which declares `VertexOutput`, the group(0) +// projection, the group(1) base-texture slot table and `sampleBase()`. +// +// group(2) binding 0 is the engine's user-uniform buffer, unused here: the +// light count and the ambient term travel in the light texture, so this +// material has no per-frame uniform to write. +@group(2) @binding(1) var u_normalMap: texture_2d; +@group(2) @binding(2) var u_normalMapSampler: sampler; +@group(2) @binding(3) var u_lights: texture_2d; +@group(2) @binding(4) var u_lightsSampler: sampler; + +@fragment +fn fragmentMain(input: VertexOutput) -> @location(0) vec4 { + let base = sampleBase(input.textureSlot, input.texcoord); + + // Rotate the tangent-space normal by the instance's local-to-world basis so + // a spinning or mirrored sprite keeps its bumps facing the right way. + let tangentNormal = textureSample(u_normalMap, u_normalMapSampler, input.texcoord).xyz * 2.0 - 1.0; + let axisX = normalize(vec2(input.basis.x, input.basis.z)); + let axisY = normalize(vec2(input.basis.y, input.basis.w)); + let normal = normalize(vec3(axisX * tangentNormal.x + axisY * tangentNormal.y, tangentNormal.z)); + + let count = i32(textureLoad(u_lights, vec2(0, 0), 0).x); + var lit = textureLoad(u_lights, vec2(0, 1), 0).rgb; + + for (var index = 0; index < count; index = index + 1) { + let light = textureLoad(u_lights, vec2(index + 1, 0), 0); + let tint = textureLoad(u_lights, vec2(index + 1, 1), 0); + let toLight = light.xy - input.worldPosition; + let falloff = clamp(1.0 - length(toLight) / light.z, 0.0, 1.0); + let direction = normalize(vec3(toLight, tint.w)); + + lit = lit + tint.rgb * (max(dot(normal, direction), 0.0) * falloff * falloff * light.w); + } + + return vec4(base.rgb * lit, base.a) * input.color; +} diff --git a/packages/exojs-lighting/src/typings.d.ts b/packages/exojs-lighting/src/typings.d.ts new file mode 100644 index 000000000..094be7cd2 --- /dev/null +++ b/packages/exojs-lighting/src/typings.d.ts @@ -0,0 +1,14 @@ +declare module '*.vert' { + const content: string; + export default content; +} + +declare module '*.frag' { + const content: string; + export default content; +} + +declare module '*.wgsl' { + const content: string; + export default content; +} diff --git a/packages/exojs-lighting/test/LightingSystem.test.ts b/packages/exojs-lighting/test/LightingSystem.test.ts new file mode 100644 index 000000000..74c1de461 --- /dev/null +++ b/packages/exojs-lighting/test/LightingSystem.test.ts @@ -0,0 +1,127 @@ +import { Color, TextureFormat } from '@codexo/exojs'; +import { describe, expect, test } from 'vitest'; + +import { LightingSystem } from '../src/LightingSystem'; +import { PointLight } from '../src/PointLight'; + +/** Row 1 starts one full texture row into the buffer. */ +const secondRow = (system: LightingSystem): number => system.lightTexture.width * 4; + +describe('LightingSystem', () => { + test('allocates one rgba32f header column plus one column per light slot', () => { + const system = new LightingSystem({ maxLights: 8 }); + + expect(system.lightTexture.format).toBe(TextureFormat.Rgba32F); + expect(system.lightTexture.width).toBe(9); + expect(system.lightTexture.height).toBe(2); + expect(system.lightTexture.buffer).toBeInstanceOf(Float32Array); + }); + + test('publishes the light count and the ambient term in the header column', () => { + const system = new LightingSystem({ maxLights: 4, ambient: new Color(51, 102, 153) }); + + system.add(new PointLight()).add(new PointLight()); + system.commit(); + + const buffer = system.lightTexture.buffer; + + expect(buffer[0]).toBe(2); + expect(system.activeLightCount).toBe(2); + expect(buffer[secondRow(system)]).toBeCloseTo(51 / 255, 6); + expect(buffer[secondRow(system) + 1]).toBeCloseTo(102 / 255, 6); + expect(buffer[secondRow(system) + 2]).toBeCloseTo(153 / 255, 6); + }); + + test('packs position, radius and intensity in row 0 and normalized colour plus height in row 1', () => { + const system = new LightingSystem({ maxLights: 4 }); + + system.add(new PointLight({ x: 120, y: -35.5, radius: 400, intensity: 2.5, height: 96, color: new Color(255, 0, 128) })); + system.commit(); + + const buffer = system.lightTexture.buffer; + const row0 = 4; + const row1 = secondRow(system) + 4; + + expect(Array.from(buffer.subarray(row0, row0 + 4))).toEqual([120, -35.5, 400, 2.5]); + expect(buffer[row1]).toBeCloseTo(1, 6); + expect(buffer[row1 + 1]).toBe(0); + expect(buffer[row1 + 2]).toBeCloseTo(128 / 255, 6); + expect(buffer[row1 + 3]).toBe(96); + }); + + test('re-reads mutable light and ambient state on every commit', () => { + const system = new LightingSystem({ maxLights: 2 }); + const light = new PointLight({ x: 1, y: 2 }); + + system.add(light); + system.commit(); + + light.setPosition(300, 400); + system.ambient.set(255, 255, 255); + system.commit(); + + expect(Array.from(system.lightTexture.buffer.subarray(4, 6))).toEqual([300, 400]); + expect(system.lightTexture.buffer[secondRow(system)]).toBe(1); + }); + + test('publishes at most maxLights and recovers capacity when a light is removed', () => { + const system = new LightingSystem({ maxLights: 2 }); + const first = new PointLight({ x: 1 }); + const surplus = new PointLight({ x: 3 }); + + system + .add(first) + .add(new PointLight({ x: 2 })) + .add(surplus); + system.commit(); + + expect(system.lights).toHaveLength(3); + expect(system.activeLightCount).toBe(2); + expect(system.lightTexture.buffer[0]).toBe(2); + + expect(system.remove(first)).toBe(true); + expect(system.remove(first)).toBe(false); + system.commit(); + + expect(system.activeLightCount).toBe(2); + expect(system.lightTexture.buffer[4]).toBe(2); + expect(system.lightTexture.buffer[8]).toBe(3); + }); + + test('clear drops every light and publishes an empty header', () => { + const system = new LightingSystem({ maxLights: 4 }); + + system.add(new PointLight()); + system.clear().commit(); + + expect(system.lights).toHaveLength(0); + expect(system.lightTexture.buffer[0]).toBe(0); + }); + + test('the update phase commits, and every commit marks the texture for upload', () => { + const system = new LightingSystem({ maxLights: 4 }); + const light = new PointLight(); + + system.add(light); + + const before = system.lightTexture.version; + + light.setPosition(64, 64); + system.update(); + + expect(system.lightTexture.version).toBeGreaterThan(before); + expect(Array.from(system.lightTexture.buffer.subarray(4, 6))).toEqual([64, 64]); + }); + + test('destroy releases the light texture and leaves the lights themselves alone', () => { + const system = new LightingSystem({ maxLights: 4 }); + const light = new PointLight({ x: 5 }); + + system.add(light); + system.destroy(); + + expect(system.lightTexture.destroyed).toBe(true); + expect(system.lights).toHaveLength(0); + expect(light.x).toBe(5); + }); +}); diff --git a/packages/exojs-lighting/test/LitSpriteMaterial.test.ts b/packages/exojs-lighting/test/LitSpriteMaterial.test.ts new file mode 100644 index 000000000..9fb4b9617 --- /dev/null +++ b/packages/exojs-lighting/test/LitSpriteMaterial.test.ts @@ -0,0 +1,64 @@ +import { BlendModes, ScaleModes, type System, Texture, WrapModes } from '@codexo/exojs'; +import { describe, expect, expectTypeOf, test } from 'vitest'; + +import { LightingSystem } from '../src/LightingSystem'; +import { LitSpriteMaterial } from '../src/LitSpriteMaterial'; + +const normalMap = (): Texture => new Texture(null); + +describe('LitSpriteMaterial', () => { + test('binds the normal map ahead of the light texture, matching the WGSL group(2) order', () => { + const lighting = new LightingSystem(); + const material = new LitSpriteMaterial({ lighting, normalMap: normalMap() }); + + expect(material._bindingSchema.textureNames).toEqual(['u_normalMap', 'u_lights']); + expect(material._bindingSchema.scalarUniformNames).toEqual([]); + expect(material.textures.u_lights).toBe(lighting.lightTexture); + expect(material.target).toBe('sprite'); + }); + + test('carries both backends and shares one shader source across instances', () => { + const lighting = new LightingSystem(); + const first = new LitSpriteMaterial({ lighting, normalMap: normalMap() }); + const second = new LitSpriteMaterial({ lighting, normalMap: normalMap() }); + + expect(first.shader.glsl?.fragment).toContain('sampleBase(v_textureSlot, v_texcoord)'); + expect(first.shader.wgsl).toContain('fn fragmentMain(input: VertexOutput)'); + expect(first.shader).toBe(second.shader); + expect(first.pipelineKey).toBe(second.pipelineKey); + }); + + test('defaults to normal blending and an inherited base sampler', () => { + const material = new LitSpriteMaterial({ lighting: new LightingSystem(), normalMap: normalMap() }); + + expect(material.blendMode).toBe(BlendModes.Normal); + expect(material.sampler).toBeNull(); + }); + + test('honours the blend mode and sampler overrides', () => { + const material = new LitSpriteMaterial({ + lighting: new LightingSystem(), + normalMap: normalMap(), + blendMode: BlendModes.Additive, + sampler: { scaleMode: ScaleModes.Nearest, wrapMode: WrapModes.Repeat }, + }); + + expect(material.blendMode).toBe(BlendModes.Additive); + expect(material.sampler).toEqual({ scaleMode: ScaleModes.Nearest, wrapMode: WrapModes.Repeat }); + }); + + test('swapping the normal map changes the bind key', () => { + const material = new LitSpriteMaterial({ lighting: new LightingSystem(), normalMap: normalMap() }); + const before = material.bindKey; + const replacement = normalMap(); + + material.normalMap = replacement; + + expect(material.normalMap).toBe(replacement); + expect(material.bindKey).not.toBe(before); + }); + + test('a LightingSystem is registrable as an engine System', () => { + expectTypeOf().toExtend(); + }); +}); diff --git a/packages/exojs-lighting/tsconfig.build.json b/packages/exojs-lighting/tsconfig.build.json new file mode 100644 index 000000000..037677bbc --- /dev/null +++ b/packages/exojs-lighting/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "_comment": "Declaration emit for the Rolldown build (`rolldown.config.ts`), which has no declaration emitter. `paths` resolves the public `@codexo/exojs*` specifiers this package's source imports against Core's built declarations (not source, unlike tsconfig.json's own paths, which back the regular source-mode typecheck).", + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "customConditions": [], + "paths": { + "@codexo/exojs": ["../../dist/esm/index.d.ts"] + } + } +} diff --git a/packages/exojs-lighting/tsconfig.json b/packages/exojs-lighting/tsconfig.json new file mode 100644 index 000000000..d9042e12c --- /dev/null +++ b/packages/exojs-lighting/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@codexo/exojs-config/typescript/extension.json", + "compilerOptions": { + "customConditions": ["@codexo/exojs-source"], + "paths": { + "@codexo/exojs": ["../../src/index.ts"] + } + }, + "include": ["src/**/*", "../../src/typings.d.ts"], + "exclude": ["dist", "node_modules", "test"] +} diff --git a/packages/exojs-lighting/tsconfig.test.json b/packages/exojs-lighting/tsconfig.test.json new file mode 100644 index 000000000..f24ad586e --- /dev/null +++ b/packages/exojs-lighting/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "_comment": "Type-checks this package's own test/** - the package's main program only includes src/**. Run by the package's 'typecheck' script. The loosened flags live in the shared package-test profile.", + "extends": ["./tsconfig.json", "@codexo/exojs-config/typescript/package-test.json"], + "include": ["test/**/*", "../../src/typings.d.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ea9aca7b..19c89c9b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,6 +253,15 @@ importers: specifier: workspace:* version: link:../exojs-tilemap + packages/exojs-lighting: + devDependencies: + '@codexo/exojs': + specifier: workspace:* + version: link:../.. + '@codexo/exojs-config': + specifier: workspace:* + version: link:../exojs-config + packages/exojs-particles: devDependencies: '@codexo/exojs': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2b2d82e9b..75174eb42 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,7 @@ packages: - packages/exojs-react - packages/exojs-audio-fx - packages/exojs-tilemap-physics + - packages/exojs-lighting - packages/create-exo-app # Packages permitted to run install/build scripts. pnpm v10+ blocks build diff --git a/scripts/ci/lanes.ts b/scripts/ci/lanes.ts index e857d95db..f1bd847c2 100644 --- a/scripts/ci/lanes.ts +++ b/scripts/ci/lanes.ts @@ -146,7 +146,7 @@ export const LANES: readonly Lane[] = [ run: 'pnpm size && pnpm size:summary && pnpm verify:exports && pnpm verify:declaration-imports && pnpm verify:lockstep && pnpm verify:release-matrix ' + '&& pnpm pack --dry-run && pnpm --filter "@codexo/exojs-build" --filter "@codexo/exojs-particles" --filter "@codexo/exojs-tilemap" ' + - '--filter "@codexo/exojs-tiled" --filter "@codexo/exojs-physics" --filter "@codexo/exojs-tilemap-physics" --filter "@codexo/exojs-audio-fx" ' + + '--filter "@codexo/exojs-tiled" --filter "@codexo/exojs-physics" --filter "@codexo/exojs-tilemap-physics" --filter "@codexo/exojs-lighting" --filter "@codexo/exojs-audio-fx" ' + '--filter "@codexo/exojs-aseprite" --filter "@codexo/exojs-ldtk" --filter "@codexo/exojs-react" pack --dry-run && pnpm verify:publint', dist: true, }, diff --git a/scripts/ci/select-lanes.ts b/scripts/ci/select-lanes.ts index 9d002f347..142424c41 100644 --- a/scripts/ci/select-lanes.ts +++ b/scripts/ci/select-lanes.ts @@ -84,6 +84,7 @@ const RUNTIME_PACKAGES = [ 'exojs-ldtk', 'exojs-react', 'exojs-tilemap-physics', + 'exojs-lighting', ]; /** diff --git a/scripts/exo-full.entry.ts b/scripts/exo-full.entry.ts index 718d8bc82..0df3cc1cf 100644 --- a/scripts/exo-full.entry.ts +++ b/scripts/exo-full.entry.ts @@ -114,5 +114,8 @@ export { ldtkToTileMap, } from '@codexo/exojs-ldtk'; +// ── Lighting ─────────────────────────────────────────────────────────────── +export * from '@codexo/exojs-lighting'; + // ── Tilemap physics bridge ──────────────────────────────────────────────────── export { buildObjectLayerColliders, TileColliderStreamer } from '@codexo/exojs-tilemap-physics'; diff --git a/scripts/release/external-consumers.ts b/scripts/release/external-consumers.ts index 5e3b15dde..b95dd071f 100644 --- a/scripts/release/external-consumers.ts +++ b/scripts/release/external-consumers.ts @@ -65,6 +65,7 @@ import { AudioAnalyser, BeatDetector, ReverbEffect } from '@codexo/exojs-audio-f import { AsepriteSheet, asepriteExtension } from '@codexo/exojs-aseprite'; import { LdtkMap, ldtkExtension } from '@codexo/exojs-ldtk'; import { TileColliderStreamer } from '@codexo/exojs-tilemap-physics'; +import { LightingSystem, LitSpriteMaterial, PointLight } from '@codexo/exojs-lighting'; export class DemoScene extends Scene {} @@ -99,6 +100,9 @@ export function bootstrap(): { app: Application; system: typeof ParticleSystem; void LdtkMap; void ldtkExtension; void TileColliderStreamer; + void LightingSystem; + void LitSpriteMaterial; + void PointLight; return { app, system: ParticleSystem, tiles: TileMap, map: TiledMap }; } `; @@ -163,6 +167,7 @@ import * as audioFx from '@codexo/exojs-audio-fx'; import * as aseprite from '@codexo/exojs-aseprite'; import * as ldtk from '@codexo/exojs-ldtk'; import * as tilemapPhysics from '@codexo/exojs-tilemap-physics'; +import * as lighting from '@codexo/exojs-lighting'; const checks = [ ['@codexo/exojs Application', typeof exo.Application === 'function'], @@ -186,6 +191,9 @@ const checks = [ ['facade ldtk TileMap identity (ldtk === tilemap)', ldtk.TileMap === tilemap.TileMap], ['@codexo/exojs-tilemap-physics TileColliderStreamer', typeof tilemapPhysics.TileColliderStreamer === 'function'], ['@codexo/exojs-tilemap-physics buildObjectLayerColliders', typeof tilemapPhysics.buildObjectLayerColliders === 'function'], + ['@codexo/exojs-lighting LightingSystem', typeof lighting.LightingSystem === 'function'], + ['@codexo/exojs-lighting LitSpriteMaterial', typeof lighting.LitSpriteMaterial === 'function'], + ['@codexo/exojs-lighting PointLight', typeof lighting.PointLight === 'function'], ]; const failed = checks.filter(([, ok]) => !ok).map(([name]) => name); if (failed.length > 0) { diff --git a/scripts/release/lockstep-packages.ts b/scripts/release/lockstep-packages.ts index a35692bde..36c28da90 100644 --- a/scripts/release/lockstep-packages.ts +++ b/scripts/release/lockstep-packages.ts @@ -45,6 +45,7 @@ export const LOCKSTEP_PACKAGES = [ { name: '@codexo/exojs-ldtk', dir: 'packages/exojs-ldtk', isExtension: true, inOfflineSmoke: true }, { name: '@codexo/exojs-react', dir: 'packages/exojs-react', isExtension: true, inOfflineSmoke: false }, { name: '@codexo/exojs-tilemap-physics', dir: 'packages/exojs-tilemap-physics', isExtension: true, inOfflineSmoke: true }, + { name: '@codexo/exojs-lighting', dir: 'packages/exojs-lighting', isExtension: true, inOfflineSmoke: true }, ] as const satisfies readonly LockstepPackage[]; /** diff --git a/site/public/preview.html b/site/public/preview.html index ffe4f8d29..e6d7a8ab1 100644 --- a/site/public/preview.html +++ b/site/public/preview.html @@ -73,6 +73,7 @@ var physicsEntry; var physicsDebugEntry; var tilemapPhysicsEntry; + var lightingEntry; if (!safeVersion || safeVersion === 'current') { exoEntry = './vendor/exojs/esm/index.js?no-cache=' + noCache; exoDebugEntry = './vendor/exojs/esm/debug/index.js?no-cache=' + noCache; @@ -87,6 +88,7 @@ physicsEntry = './vendor/exojs-physics/esm/index.js?no-cache=' + noCache; physicsDebugEntry = './vendor/exojs-physics/esm/debug/index.js?no-cache=' + noCache; tilemapPhysicsEntry = './vendor/exojs-tilemap-physics/esm/index.js?no-cache=' + noCache; + lightingEntry = './vendor/exojs-lighting/esm/index.js?no-cache=' + noCache; } else { // jsDelivr serves immutable npm tarballs — the version pin // alone is enough, no cache-buster needed. @@ -104,6 +106,7 @@ physicsEntry = cdnBase + 'exojs-physics@' + safeVersion + '/dist/esm/index.js'; physicsDebugEntry = cdnBase + 'exojs-physics@' + safeVersion + '/dist/esm/debug/index.js'; tilemapPhysicsEntry = cdnBase + 'exojs-tilemap-physics@' + safeVersion + '/dist/esm/index.js'; + lightingEntry = cdnBase + 'exojs-lighting@' + safeVersion + '/dist/esm/index.js'; } var importMap = { @@ -121,6 +124,7 @@ '@codexo/exojs-physics': physicsEntry, '@codexo/exojs-physics/debug': physicsDebugEntry, '@codexo/exojs-tilemap-physics': tilemapPhysicsEntry, + '@codexo/exojs-lighting': lightingEntry, '@examples/runtime': './examples/shared/runtime.js?no-cache=' + noCache, '@examples/terrain-noise': './examples/shared/terrain-noise.js?no-cache=' + noCache, }, diff --git a/site/scripts/build-api.ts b/site/scripts/build-api.ts index 488f2f5aa..6271611a5 100644 --- a/site/scripts/build-api.ts +++ b/site/scripts/build-api.ts @@ -111,6 +111,13 @@ const EXTENSION_PACKAGES: readonly ExtensionPackage[] = [ tsconfig: 'packages/exojs-tilemap-physics/tsconfig.json', sourceMarker: 'packages/exojs-tilemap-physics/src/', }, + { + importPath: '@codexo/exojs-lighting', + subsystem: 'lighting', + entryPoint: 'packages/exojs-lighting/src/index.ts', + tsconfig: 'packages/exojs-lighting/tsconfig.json', + sourceMarker: 'packages/exojs-lighting/src/', + }, { importPath: '@codexo/exojs-ldtk', subsystem: 'ldtk', diff --git a/site/scripts/sync-exo-vendor.ts b/site/scripts/sync-exo-vendor.ts index 8d7c9caa1..d7fce71cf 100644 --- a/site/scripts/sync-exo-vendor.ts +++ b/site/scripts/sync-exo-vendor.ts @@ -434,6 +434,7 @@ const syncVendor = (): void => { 'exojs-ldtk', 'exojs-physics', 'exojs-tilemap-physics', + 'exojs-lighting', ] as const; for (const pkgName of extensionPackages) { let pkgRoot: string; diff --git a/site/src/components/EditorCode.tsx b/site/src/components/EditorCode.tsx index 661f7ef27..1bfe8e519 100644 --- a/site/src/components/EditorCode.tsx +++ b/site/src/components/EditorCode.tsx @@ -655,6 +655,7 @@ const EXTENSION_PACKAGES: ReadonlyArray<{ baseUrl: string; packageName: string } { baseUrl: 'vendor/exojs-ldtk/', packageName: '@codexo/exojs-ldtk' }, { baseUrl: 'vendor/exojs-physics/', packageName: '@codexo/exojs-physics' }, { baseUrl: 'vendor/exojs-tilemap-physics/', packageName: '@codexo/exojs-tilemap-physics' }, + { baseUrl: 'vendor/exojs-lighting/', packageName: '@codexo/exojs-lighting' }, ]; const loadTypingsForVersion = async (versionId: string): Promise> => { diff --git a/site/src/lib/api-reference.ts b/site/src/lib/api-reference.ts index 554a8d61b..54423cdff 100644 --- a/site/src/lib/api-reference.ts +++ b/site/src/lib/api-reference.ts @@ -18,6 +18,7 @@ export const API_SUBSYSTEM_ORDER = [ 'tiled', 'physics', 'tilemap-physics', + 'lighting', 'aseprite', 'ldtk', ] as const; @@ -93,6 +94,10 @@ export const API_SUBSYSTEM_META: Record Date: Fri, 4 Sep 2026 17:30:12 +0200 Subject: [PATCH 2/3] feat(lighting): move the lighting examples onto the package and cover both backends normal-mapped-sprites drops its hand-written material and uniform bookkeeping for LightingSystem/LitSpriteMaterial. many-lights is new: a slider walks the scene from 1 to 48 point lights over a normal-mapped floor, which is only a slider because the light list is a data texture - a uniform array would have been a recompile per count, and a cap far below 48. Browser pixel tests on both backends assert the same three facts: the falloff reaches the framebuffer, a mirrored instance shades identically to an upright one, and the two quads stay in a single draw call. The WebGPU one also pops a validation scope, because binding an rgba32f texture through the sprite material's group(2) layout is the part that could silently be invalid. Claude-Session: https://claude.ai/code/session_01NSrXpH1WyqP7udpWWhHz1i --- CHANGELOG.md | 22 +- examples/examples.json | 14 +- examples/lighting/many-lights.js | 155 +++ examples/lighting/many-lights.ts | 203 ++++ examples/lighting/normal-mapped-sprites.js | 109 +-- examples/lighting/normal-mapped-sprites.ts | 119 +-- scripts/release/RELEASING.md | 19 +- .../content/api/lighting-system-options.json | 100 ++ site/src/content/api/lighting-system.json | 463 +++++++++ .../api/lit-sprite-material-options.json | 150 +++ site/src/content/api/lit-sprite-material.json | 909 ++++++++++++++++++ site/src/content/api/point-light-options.json | 200 ++++ site/src/content/api/point-light.json | 310 ++++++ .../rendering/browser/webgl2-lighting.test.ts | 180 ++++ .../rendering/browser/webgpu-lighting.test.ts | 205 ++++ 15 files changed, 2960 insertions(+), 198 deletions(-) create mode 100644 examples/lighting/many-lights.js create mode 100644 examples/lighting/many-lights.ts create mode 100644 site/src/content/api/lighting-system-options.json create mode 100644 site/src/content/api/lighting-system.json create mode 100644 site/src/content/api/lit-sprite-material-options.json create mode 100644 site/src/content/api/lit-sprite-material.json create mode 100644 site/src/content/api/point-light-options.json create mode 100644 site/src/content/api/point-light.json create mode 100644 test/rendering/browser/webgl2-lighting.test.ts create mode 100644 test/rendering/browser/webgpu-lighting.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d2ee17c0..d47136776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,13 +34,31 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and shadow opacity) and `shadowOnly` for glows and detached shadows. Composed from the stock colour-matrix and blur passes, so it runs on both backends and declares the extra reach it needs through `getOutputBounds`. +- **`@codexo/exojs-lighting`, forward normal-mapped point lighting for + sprites.** Lighting happens inside the sprite fragment stage, so a lit scene + costs no extra render pass and no extra draw call - sprites sharing one + `LitSpriteMaterial` stay in one batch. `PointLight` is plain mutable + world-space data (`x`, `y`, `radius`, `color`, `intensity`, `height`); + `LightingSystem` collects lights and packs them, together with the active + count and the ambient term, into one `rgba32f` data texture per frame, and + registers on any `SystemRegistry` like every other system. The light list + being a texture rather than a uniform array is what makes the light count a + shader loop bound instead of a compiled-in constant: a material user uniform + is one `vec4` per name, which would have capped a scene at a handful of + lights and needed a recompile to change. `LitSpriteMaterial` samples a + tangent-space normal map next to the albedo and rotates the normal by the + instance's local-to-world basis, so spinning and mirrored sprites keep their + bumps facing the right way. One normal map per material (= per atlas) is the + v1 contract; there is no deferred path yet. Two examples ship with it: + `lighting/normal-mapped-sprites` and `lighting/many-lights`, which walks from + 1 to 48 lights over a normal-mapped floor without leaving a single draw call. - **Custom sprite materials receive the fragment's world position and the instance's local-to-world basis.** `v_worldPosition` / `v_basis` (GLSL) and `worldPosition` / `basis` on `VertexOutput` (WGSL) let a fragment shade against world-space lights and rotate a tangent-space normal with the sprite, which is what a lighting effect needs and what the varyings did not - carry before. The new `lighting/normal-mapped-sprites` example lights a batch - of spinning and mirrored sprites through one material and four point lights. + carry before. The `lighting/normal-mapped-sprites` example lights a batch of + spinning and mirrored sprites through one material and four point lights. - **`Scene.animations`, a scene-bound animation facade with the same `when` policy the tween and audio facades already have.** An `AnimatedSprite` attached to a scene tree kept advancing through `SceneDirector.pause()` and diff --git a/examples/examples.json b/examples/examples.json index 5a96f6810..436ea13aa 100644 --- a/examples/examples.json +++ b/examples/examples.json @@ -688,12 +688,24 @@ "path": "lighting/normal-mapped-sprites.js", "language": "typescript", "title": "Normal-Mapped Sprites", - "description": "Forward normal mapping on plain sprites: a custom SpriteMaterial samples a tangent-space normal map and shades every sprite against four moving point lights in a single batch, with spinning and mirrored tiles proving the basis rotation.", + "description": "Forward normal mapping on plain sprites: a LitSpriteMaterial samples a tangent-space normal map and shades every sprite against four moving point lights in a single batch, with spinning and mirrored tiles proving the basis rotation.", "backend": "core", "featured": false, "tags": ["lighting", "material", "shader"], "capabilities": [], "level": "advanced" + }, + { + "slug": "many-lights", + "path": "lighting/many-lights.js", + "language": "typescript", + "title": "Many Lights", + "description": "Drag the slider from 1 to 48 point lights over a normal-mapped floor: the lights live in a data texture, so the count is a shader loop bound rather than a recompile, and the whole floor stays a single draw call.", + "backend": "core", + "featured": false, + "tags": ["lighting", "material", "shader", "performance"], + "capabilities": [], + "level": "advanced" } ], "particles": [ diff --git a/examples/lighting/many-lights.js b/examples/lighting/many-lights.js new file mode 100644 index 000000000..e28ce63e7 --- /dev/null +++ b/examples/lighting/many-lights.js @@ -0,0 +1,155 @@ +// Auto-generated from many-lights.ts - edit the .ts source, not this file. +import { Application, Color, Container, FixedResolutionCanvasSizing, ScaleModes, Scene, Sprite, Texture } from '@codexo/exojs'; +import { LightingSystem, LitSpriteMaterial, PointLight } from '@codexo/exojs-lighting'; +import { mountControlPanel, mountControls } from '@examples/runtime'; +// The light list is a data texture, not a uniform array, so the light count is +// a shader loop bound rather than a compiled-in constant: the slider below +// walks from 1 to 48 lights without recompiling anything and without adding a +// draw call. The floor is one batch of sprites sharing one LitSpriteMaterial. +const MAX_LIGHTS = 48; +const TILE_SIZE = 128; +const HUE_STEP = 360 / 7; +const canvasTexture = (size, paint) => { + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const context = canvas.getContext('2d'); + if (context === null) throw new Error('2D canvas context unavailable.'); + paint(context); + return new Texture(canvas, { scaleMode: ScaleModes.Linear, generateMipMap: false }); +}; +// Flat stone albedo with a mortar cross, so the tiling is visible even unlit. +const albedoTexture = canvasTexture(TILE_SIZE, context => { + context.fillStyle = '#9a958c'; + context.fillRect(0, 0, TILE_SIZE, TILE_SIZE); + context.fillStyle = '#6e6a63'; + context.fillRect(0, 0, TILE_SIZE, 4); + context.fillRect(0, 0, 4, TILE_SIZE); +}); +// Matching normal map: a rounded bevel around the tile edge and a shallow dome +// in the middle, so a light sweeping past visibly rakes across the relief. +const normalTexture = canvasTexture(TILE_SIZE, context => { + const image = context.createImageData(TILE_SIZE, TILE_SIZE); + const half = TILE_SIZE / 2; + const bevel = 14; + for (let y = 0; y < TILE_SIZE; y++) { + for (let x = 0; x < TILE_SIZE; x++) { + const edge = Math.min(x, y, TILE_SIZE - 1 - x, TILE_SIZE - 1 - y); + const slope = edge < bevel ? 1 - edge / bevel : 0; + const towardsX = x < half ? -1 : 1; + const towardsY = y < half ? -1 : 1; + const horizontal = Math.min(x, TILE_SIZE - 1 - x) <= Math.min(y, TILE_SIZE - 1 - y); + const nx = horizontal ? towardsX * slope : ((x - half) / half) * 0.25; + const ny = horizontal ? ((y - half) / half) * 0.25 : towardsY * slope; + const length = Math.hypot(nx, ny, 1); + const offset = (y * TILE_SIZE + x) * 4; + image.data[offset] = ((nx / length) * 0.5 + 0.5) * 255; + image.data[offset + 1] = ((ny / length) * 0.5 + 0.5) * 255; + image.data[offset + 2] = (1 / length) * 0.5 * 255 + 127.5; + image.data[offset + 3] = 255; + } + } + context.putImageData(image, 0, 0); +}); +// Evenly spaced hues at a fixed lightness, so neighbouring pools of light stay +// distinguishable without any of them blowing out to white. +const lightColor = index => { + const hue = (index * HUE_STEP) % 360; + const component = offset => { + const k = (offset + hue / 30) % 12; + return Math.round(255 * (0.62 - 0.38 * Math.max(-1, Math.min(k - 3, 9 - k, 1)))); + }; + return new Color(component(0), component(8), component(4)); +}; +class ManyLightsScene extends Scene { + floor; + markerLayer; + lighting; + orbits; + visibleLights = 24; + elapsed = 0; + hud; + init() { + const { width, height } = this.app; + this.floor = new Container(); + this.markerLayer = new Container(); + this.lighting = new LightingSystem({ maxLights: MAX_LIGHTS, ambient: new Color(16, 16, 24) }); + this.systems.add(this.lighting); + const material = new LitSpriteMaterial({ lighting: this.lighting, normalMap: normalTexture }); + for (let y = 0; y < Math.ceil(height / TILE_SIZE); y++) { + for (let x = 0; x < Math.ceil(width / TILE_SIZE); x++) { + const tile = new Sprite(albedoTexture); + tile.setPosition(x * TILE_SIZE, y * TILE_SIZE); + tile.material = material; + this.floor.addChild(tile); + } + } + this.orbits = Array.from({ length: MAX_LIGHTS }, (_, index) => { + const color = lightColor(index); + const light = new PointLight({ radius: 190, intensity: 1.6, height: 46, color }); + const marker = new Sprite(Texture.fromColor(color, 6)).setAnchor(0.5); + this.markerLayer.addChild(marker); + return { + light, + marker, + speed: 0.25 + (index % 7) * 0.08, + phase: (index / MAX_LIGHTS) * Math.PI * 2, + radiusX: width * (0.18 + ((index % 5) / 5) * 0.28), + radiusY: height * (0.16 + ((index % 3) / 3) * 0.3), + }; + }); + this.setVisibleLights(this.visibleLights); + this.hud = mountControls({ + title: 'Many Lights', + hint: `Up to ${MAX_LIGHTS} point lights over ${this.floor.children.length} tiles. The light list is a data texture, so the count is a loop bound - not a recompile.`, + status: '', + }); + const panel = mountControlPanel({ title: 'Lights', corner: 'top-right' }); + panel.addSlider({ + label: 'Active lights', + min: 1, + max: MAX_LIGHTS, + step: 1, + value: this.visibleLights, + onChange: value => this.setVisibleLights(value), + }); + } + setVisibleLights(count) { + this.visibleLights = count; + this.lighting.clear(); + for (let index = 0; index < this.orbits.length; index++) { + const orbit = this.orbits[index]; + const active = index < count; + orbit.marker.visible = active; + if (active) this.lighting.add(orbit.light); + } + } + update(delta) { + const { width, height } = this.app; + this.elapsed += delta; + for (let index = 0; index < this.visibleLights; index++) { + const orbit = this.orbits[index]; + const angle = this.elapsed * orbit.speed + orbit.phase; + const x = width / 2 + Math.cos(angle) * orbit.radiusX; + const y = height / 2 + Math.sin(angle * 1.37 + orbit.phase) * orbit.radiusY; + orbit.light.setPosition(x, y); + orbit.marker.setPosition(x, y); + } + } + draw(context) { + context.render(this.floor); + context.render(this.markerLayer); + this.hud.setStatus(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`); + } +} +const app = new Application({ + scenes: { ManyLightsScene }, + canvas: { + width: 1280, + height: 720, + mount: document.body, + sizing: new FixedResolutionCanvasSizing(), + }, + clearColor: new Color(8, 8, 12), +}); +await app.start(ManyLightsScene); diff --git a/examples/lighting/many-lights.ts b/examples/lighting/many-lights.ts new file mode 100644 index 000000000..bcb36b4f0 --- /dev/null +++ b/examples/lighting/many-lights.ts @@ -0,0 +1,203 @@ +import { + Application, + Color, + Container, + FixedResolutionCanvasSizing, + type RenderingContext, + ScaleModes, + Scene, + type Seconds, + Sprite, + Texture, +} from '@codexo/exojs'; +import { LightingSystem, LitSpriteMaterial, PointLight } from '@codexo/exojs-lighting'; +import { mountControlPanel, mountControls } from '@examples/runtime'; + +// The light list is a data texture, not a uniform array, so the light count is +// a shader loop bound rather than a compiled-in constant: the slider below +// walks from 1 to 48 lights without recompiling anything and without adding a +// draw call. The floor is one batch of sprites sharing one LitSpriteMaterial. + +const MAX_LIGHTS = 48; +const TILE_SIZE = 128; +const HUE_STEP = 360 / 7; + +const canvasTexture = (size: number, paint: (context: CanvasRenderingContext2D) => void): Texture => { + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const context = canvas.getContext('2d'); + if (context === null) throw new Error('2D canvas context unavailable.'); + paint(context); + return new Texture(canvas, { scaleMode: ScaleModes.Linear, generateMipMap: false }); +}; + +// Flat stone albedo with a mortar cross, so the tiling is visible even unlit. +const albedoTexture = canvasTexture(TILE_SIZE, context => { + context.fillStyle = '#9a958c'; + context.fillRect(0, 0, TILE_SIZE, TILE_SIZE); + context.fillStyle = '#6e6a63'; + context.fillRect(0, 0, TILE_SIZE, 4); + context.fillRect(0, 0, 4, TILE_SIZE); +}); + +// Matching normal map: a rounded bevel around the tile edge and a shallow dome +// in the middle, so a light sweeping past visibly rakes across the relief. +const normalTexture = canvasTexture(TILE_SIZE, context => { + const image = context.createImageData(TILE_SIZE, TILE_SIZE); + const half = TILE_SIZE / 2; + const bevel = 14; + for (let y = 0; y < TILE_SIZE; y++) { + for (let x = 0; x < TILE_SIZE; x++) { + const edge = Math.min(x, y, TILE_SIZE - 1 - x, TILE_SIZE - 1 - y); + const slope = edge < bevel ? 1 - edge / bevel : 0; + const towardsX = x < half ? -1 : 1; + const towardsY = y < half ? -1 : 1; + const horizontal = Math.min(x, TILE_SIZE - 1 - x) <= Math.min(y, TILE_SIZE - 1 - y); + const nx = horizontal ? towardsX * slope : ((x - half) / half) * 0.25; + const ny = horizontal ? ((y - half) / half) * 0.25 : towardsY * slope; + const length = Math.hypot(nx, ny, 1); + const offset = (y * TILE_SIZE + x) * 4; + image.data[offset] = ((nx / length) * 0.5 + 0.5) * 255; + image.data[offset + 1] = ((ny / length) * 0.5 + 0.5) * 255; + image.data[offset + 2] = (1 / length) * 0.5 * 255 + 127.5; + image.data[offset + 3] = 255; + } + } + context.putImageData(image, 0, 0); +}); + +// Evenly spaced hues at a fixed lightness, so neighbouring pools of light stay +// distinguishable without any of them blowing out to white. +const lightColor = (index: number): Color => { + const hue = (index * HUE_STEP) % 360; + const component = (offset: number): number => { + const k = (offset + hue / 30) % 12; + return Math.round(255 * (0.62 - 0.38 * Math.max(-1, Math.min(k - 3, 9 - k, 1)))); + }; + + return new Color(component(0), component(8), component(4)); +}; + +interface Orbit { + readonly light: PointLight; + readonly marker: Sprite; + readonly speed: number; + readonly phase: number; + readonly radiusX: number; + readonly radiusY: number; +} + +class ManyLightsScene extends Scene { + private floor!: Container; + private markerLayer!: Container; + private lighting!: LightingSystem; + private orbits!: Orbit[]; + private visibleLights = 24; + private elapsed = 0; + private hud!: ReturnType; + + override init(): void { + const { width, height } = this.app; + + this.floor = new Container(); + this.markerLayer = new Container(); + this.lighting = new LightingSystem({ maxLights: MAX_LIGHTS, ambient: new Color(16, 16, 24) }); + this.systems.add(this.lighting); + + const material = new LitSpriteMaterial({ lighting: this.lighting, normalMap: normalTexture }); + + for (let y = 0; y < Math.ceil(height / TILE_SIZE); y++) { + for (let x = 0; x < Math.ceil(width / TILE_SIZE); x++) { + const tile = new Sprite(albedoTexture); + tile.setPosition(x * TILE_SIZE, y * TILE_SIZE); + tile.material = material; + this.floor.addChild(tile); + } + } + + this.orbits = Array.from({ length: MAX_LIGHTS }, (_, index) => { + const color = lightColor(index); + const light = new PointLight({ radius: 190, intensity: 1.6, height: 46, color }); + const marker = new Sprite(Texture.fromColor(color, 6)).setAnchor(0.5); + + this.markerLayer.addChild(marker); + + return { + light, + marker, + speed: 0.25 + (index % 7) * 0.08, + phase: (index / MAX_LIGHTS) * Math.PI * 2, + radiusX: width * (0.18 + ((index % 5) / 5) * 0.28), + radiusY: height * (0.16 + ((index % 3) / 3) * 0.3), + }; + }); + + this.setVisibleLights(this.visibleLights); + + this.hud = mountControls({ + title: 'Many Lights', + hint: `Up to ${MAX_LIGHTS} point lights over ${this.floor.children.length} tiles. The light list is a data texture, so the count is a loop bound - not a recompile.`, + status: '', + }); + + const panel = mountControlPanel({ title: 'Lights', corner: 'top-right' }); + + panel.addSlider({ + label: 'Active lights', + min: 1, + max: MAX_LIGHTS, + step: 1, + value: this.visibleLights, + onChange: value => this.setVisibleLights(value), + }); + } + + private setVisibleLights(count: number): void { + this.visibleLights = count; + this.lighting.clear(); + + for (let index = 0; index < this.orbits.length; index++) { + const orbit = this.orbits[index]!; + const active = index < count; + + orbit.marker.visible = active; + + if (active) this.lighting.add(orbit.light); + } + } + + override update(delta: Seconds): void { + const { width, height } = this.app; + this.elapsed += delta; + + for (let index = 0; index < this.visibleLights; index++) { + const orbit = this.orbits[index]!; + const angle = this.elapsed * orbit.speed + orbit.phase; + const x = width / 2 + Math.cos(angle) * orbit.radiusX; + const y = height / 2 + Math.sin(angle * 1.37 + orbit.phase) * orbit.radiusY; + + orbit.light.setPosition(x, y); + orbit.marker.setPosition(x, y); + } + } + + override draw(context: RenderingContext): void { + context.render(this.floor); + context.render(this.markerLayer); + this.hud.setStatus(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`); + } +} + +const app = new Application({ + scenes: { ManyLightsScene }, + canvas: { + width: 1280, + height: 720, + mount: document.body, + sizing: new FixedResolutionCanvasSizing(), + }, + clearColor: new Color(8, 8, 12), +}); + +await app.start(ManyLightsScene); diff --git a/examples/lighting/normal-mapped-sprites.js b/examples/lighting/normal-mapped-sprites.js index 85e311a34..5afddb239 100644 --- a/examples/lighting/normal-mapped-sprites.js +++ b/examples/lighting/normal-mapped-sprites.js @@ -1,12 +1,12 @@ // Auto-generated from normal-mapped-sprites.ts - edit the .ts source, not this file. -import { Application, Color, Container, FixedResolutionCanvasSizing, ScaleModes, Scene, ShaderSource, Sprite, SpriteMaterial, Texture } from '@codexo/exojs'; +import { Application, Color, Container, FixedResolutionCanvasSizing, ScaleModes, Scene, Sprite, Texture } from '@codexo/exojs'; +import { LightingSystem, LitSpriteMaterial, PointLight } from '@codexo/exojs-lighting'; import { mountControls } from '@examples/runtime'; -// Forward normal mapping on plain sprites: a custom SpriteMaterial samples a +// Forward normal mapping on plain sprites. A LitSpriteMaterial samples a // tangent-space normal map next to the base texture and shades each fragment -// against a handful of point lights passed in as uniforms. Everything stays in -// one batch: the lights live in the material, not in extra draw calls. +// against the lights a LightingSystem publishes. Everything stays in one batch: +// the lights live in a data texture, not in extra draw calls. const LIGHT_COUNT = 4; -const LIGHT_HEIGHT = 80; const TILE_SIZE = 96; // Draw into a canvas and wrap it as a texture. Both textures below are // generated so the example carries no asset files. @@ -61,89 +61,12 @@ const normalTexture = canvasTexture(TILE_SIZE, context => { } context.putImageData(image, 0, 0); }); -// The engine owns the vertex stage of a sprite material; the GLSL vertex source -// is required by ShaderSource but never compiled for sprites. -const vertexGlsl = `#version 300 es -void main() { gl_Position = vec4(0.0); }`; -const lightUniformsGlsl = Array.from({ length: LIGHT_COUNT }, (_, index) => `uniform vec4 u_light${index};\nuniform vec4 u_lightColor${index};`).join('\n'); -const lightSumGlsl = Array.from({ length: LIGHT_COUNT }, (_, index) => `lit += shade(u_light${index}, u_lightColor${index}, normal);`).join('\n '); -const fragmentGlsl = `#version 300 es -precision mediump float; -in vec2 v_texcoord; -in vec4 v_color; -in vec2 v_worldPosition; -flat in vec4 v_basis; -uniform sampler2D u_normalMap; -uniform vec4 u_ambient; -${lightUniformsGlsl} -out vec4 fragColor; - -vec3 shade(vec4 light, vec4 color, vec3 normal) { - vec2 toLight = light.xy - v_worldPosition; - float falloff = clamp(1.0 - length(toLight) / light.z, 0.0, 1.0); - vec3 direction = normalize(vec3(toLight, ${LIGHT_HEIGHT.toFixed(1)})); - return color.rgb * (max(dot(normal, direction), 0.0) * falloff * falloff * light.w); -} - -void main() { - vec4 base = sampleBase(v_textureSlot, v_texcoord); - vec3 n = texture(u_normalMap, v_texcoord).xyz * 2.0 - 1.0; - // Rotate the tangent-space normal by the sprite's local-to-world basis so a - // spinning or flipped sprite keeps its bumps facing the right way. - vec2 axisX = normalize(vec2(v_basis.x, v_basis.z)); - vec2 axisY = normalize(vec2(v_basis.y, v_basis.w)); - vec3 normal = normalize(vec3(axisX * n.x + axisY * n.y, n.z)); - vec3 lit = u_ambient.rgb; - ${lightSumGlsl} - fragColor = vec4(base.rgb * lit, base.a) * v_color; -}`; -const lightFieldsWgsl = Array.from({ length: LIGHT_COUNT }, (_, index) => `light${index}: vec4, lightColor${index}: vec4,`).join('\n '); -const lightSumWgsl = Array.from( - { length: LIGHT_COUNT }, - (_, index) => `lit += shade(u_user.light${index}, u_user.lightColor${index}, normal, input.worldPosition);`, -).join('\n '); -const fragmentWgsl = ` -struct UserUniforms { - ambient: vec4, - ${lightFieldsWgsl} -}; -@group(2) @binding(0) var u_user: UserUniforms; -@group(2) @binding(1) var u_normalMap: texture_2d; -@group(2) @binding(2) var u_normalMapSampler: sampler; - -fn shade(light: vec4, color: vec4, normal: vec3, worldPosition: vec2) -> vec3 { - let toLight = light.xy - worldPosition; - let falloff = clamp(1.0 - length(toLight) / light.z, 0.0, 1.0); - let direction = normalize(vec3(toLight, ${LIGHT_HEIGHT.toFixed(1)})); - return color.rgb * (max(dot(normal, direction), 0.0) * falloff * falloff * light.w); -} - -@fragment -fn fragmentMain(input: VertexOutput) -> @location(0) vec4 { - let base = sampleBase(input.textureSlot, input.texcoord); - let n = textureSample(u_normalMap, u_normalMapSampler, input.texcoord).xyz * 2.0 - 1.0; - let axisX = normalize(vec2(input.basis.x, input.basis.z)); - let axisY = normalize(vec2(input.basis.y, input.basis.w)); - let normal = normalize(vec3(axisX * n.x + axisY * n.y, n.z)); - var lit = u_user.ambient.rgb; - ${lightSumWgsl} - return vec4(base.rgb * lit, base.a) * input.color; -}`; -// Uniform declaration order is the WGSL struct order: ambient first, then each -// light's position/radius/intensity followed by its colour. -const lightUniforms = { u_ambient: [0.12, 0.12, 0.16, 0] }; -for (let index = 0; index < LIGHT_COUNT; index++) { - lightUniforms[`u_light${index}`] = [0, 0, 1, 0]; - lightUniforms[`u_lightColor${index}`] = [1, 1, 1, 0]; -} -const litMaterial = new SpriteMaterial({ - shader: new ShaderSource({ glsl: { vertex: vertexGlsl, fragment: fragmentGlsl }, wgsl: fragmentWgsl }), - uniforms: lightUniforms, - textures: { u_normalMap: normalTexture }, -}); const lightColors = [new Color(255, 180, 120), new Color(120, 180, 255), new Color(160, 255, 160), new Color(255, 120, 200)]; class NormalMappedSpritesScene extends Scene { layer; + lighting; + material; + lights; tiles; markers; elapsed = 0; @@ -151,6 +74,16 @@ class NormalMappedSpritesScene extends Scene { init() { const { width, height } = this.app; this.layer = new Container(); + this.lighting = new LightingSystem({ maxLights: LIGHT_COUNT, ambient: new Color(30, 30, 40) }); + this.material = new LitSpriteMaterial({ lighting: this.lighting, normalMap: normalTexture }); + // Scene systems tick after Scene.update(), so the packed light texture + // always describes the frame that is about to be drawn. + this.systems.add(this.lighting); + this.lights = lightColors.map(color => { + const light = new PointLight({ radius: 320, intensity: 1.4, height: 80, color }); + this.lighting.add(light); + return light; + }); const columns = 8; const rows = 4; const spacing = 140; @@ -165,7 +98,7 @@ class NormalMappedSpritesScene extends Scene { const sprite = new Sprite(albedoTexture).setAnchor(0.5); sprite.setPosition(originX + column * spacing, originY + row * spacing); sprite.setScale(index % 3 === 0 ? -1 : 1, 1); - sprite.material = litMaterial; + sprite.material = this.material; this.layer.addChild(sprite); this.tiles.push({ sprite, spin: index % 2 === 0 ? 0 : index % 4 === 1 ? 45 : -30 }); } @@ -192,9 +125,7 @@ class NormalMappedSpritesScene extends Scene { const phase = this.elapsed * (0.4 + index * 0.15) + (index * Math.PI) / 2; const x = width / 2 + Math.cos(phase) * (width * 0.36); const y = height / 2 + Math.sin(phase * 1.3) * (height * 0.36); - const color = lightColors[index]; - litMaterial.uniforms[`u_light${index}`] = [x, y, 320, 1.4]; - litMaterial.uniforms[`u_lightColor${index}`] = [color.r / 255, color.g / 255, color.b / 255, 0]; + this.lights[index].setPosition(x, y); this.markers[index].setPosition(x, y); } } diff --git a/examples/lighting/normal-mapped-sprites.ts b/examples/lighting/normal-mapped-sprites.ts index 4dbf7cdf9..c620b467f 100644 --- a/examples/lighting/normal-mapped-sprites.ts +++ b/examples/lighting/normal-mapped-sprites.ts @@ -7,20 +7,18 @@ import { ScaleModes, Scene, type Seconds, - ShaderSource, Sprite, - SpriteMaterial, Texture, } from '@codexo/exojs'; +import { LightingSystem, LitSpriteMaterial, PointLight } from '@codexo/exojs-lighting'; import { mountControls } from '@examples/runtime'; -// Forward normal mapping on plain sprites: a custom SpriteMaterial samples a +// Forward normal mapping on plain sprites. A LitSpriteMaterial samples a // tangent-space normal map next to the base texture and shades each fragment -// against a handful of point lights passed in as uniforms. Everything stays in -// one batch: the lights live in the material, not in extra draw calls. +// against the lights a LightingSystem publishes. Everything stays in one batch: +// the lights live in a data texture, not in extra draw calls. const LIGHT_COUNT = 4; -const LIGHT_HEIGHT = 80; const TILE_SIZE = 96; // Draw into a canvas and wrap it as a texture. Both textures below are @@ -79,97 +77,13 @@ const normalTexture = canvasTexture(TILE_SIZE, context => { context.putImageData(image, 0, 0); }); -// The engine owns the vertex stage of a sprite material; the GLSL vertex source -// is required by ShaderSource but never compiled for sprites. -const vertexGlsl = `#version 300 es -void main() { gl_Position = vec4(0.0); }`; - -const lightUniformsGlsl = Array.from({ length: LIGHT_COUNT }, (_, index) => `uniform vec4 u_light${index};\nuniform vec4 u_lightColor${index};`).join('\n'); -const lightSumGlsl = Array.from({ length: LIGHT_COUNT }, (_, index) => `lit += shade(u_light${index}, u_lightColor${index}, normal);`).join('\n '); - -const fragmentGlsl = `#version 300 es -precision mediump float; -in vec2 v_texcoord; -in vec4 v_color; -in vec2 v_worldPosition; -flat in vec4 v_basis; -uniform sampler2D u_normalMap; -uniform vec4 u_ambient; -${lightUniformsGlsl} -out vec4 fragColor; - -vec3 shade(vec4 light, vec4 color, vec3 normal) { - vec2 toLight = light.xy - v_worldPosition; - float falloff = clamp(1.0 - length(toLight) / light.z, 0.0, 1.0); - vec3 direction = normalize(vec3(toLight, ${LIGHT_HEIGHT.toFixed(1)})); - return color.rgb * (max(dot(normal, direction), 0.0) * falloff * falloff * light.w); -} - -void main() { - vec4 base = sampleBase(v_textureSlot, v_texcoord); - vec3 n = texture(u_normalMap, v_texcoord).xyz * 2.0 - 1.0; - // Rotate the tangent-space normal by the sprite's local-to-world basis so a - // spinning or flipped sprite keeps its bumps facing the right way. - vec2 axisX = normalize(vec2(v_basis.x, v_basis.z)); - vec2 axisY = normalize(vec2(v_basis.y, v_basis.w)); - vec3 normal = normalize(vec3(axisX * n.x + axisY * n.y, n.z)); - vec3 lit = u_ambient.rgb; - ${lightSumGlsl} - fragColor = vec4(base.rgb * lit, base.a) * v_color; -}`; - -const lightFieldsWgsl = Array.from({ length: LIGHT_COUNT }, (_, index) => `light${index}: vec4, lightColor${index}: vec4,`).join('\n '); -const lightSumWgsl = Array.from( - { length: LIGHT_COUNT }, - (_, index) => `lit += shade(u_user.light${index}, u_user.lightColor${index}, normal, input.worldPosition);`, -).join('\n '); - -const fragmentWgsl = ` -struct UserUniforms { - ambient: vec4, - ${lightFieldsWgsl} -}; -@group(2) @binding(0) var u_user: UserUniforms; -@group(2) @binding(1) var u_normalMap: texture_2d; -@group(2) @binding(2) var u_normalMapSampler: sampler; - -fn shade(light: vec4, color: vec4, normal: vec3, worldPosition: vec2) -> vec3 { - let toLight = light.xy - worldPosition; - let falloff = clamp(1.0 - length(toLight) / light.z, 0.0, 1.0); - let direction = normalize(vec3(toLight, ${LIGHT_HEIGHT.toFixed(1)})); - return color.rgb * (max(dot(normal, direction), 0.0) * falloff * falloff * light.w); -} - -@fragment -fn fragmentMain(input: VertexOutput) -> @location(0) vec4 { - let base = sampleBase(input.textureSlot, input.texcoord); - let n = textureSample(u_normalMap, u_normalMapSampler, input.texcoord).xyz * 2.0 - 1.0; - let axisX = normalize(vec2(input.basis.x, input.basis.z)); - let axisY = normalize(vec2(input.basis.y, input.basis.w)); - let normal = normalize(vec3(axisX * n.x + axisY * n.y, n.z)); - var lit = u_user.ambient.rgb; - ${lightSumWgsl} - return vec4(base.rgb * lit, base.a) * input.color; -}`; - -// Uniform declaration order is the WGSL struct order: ambient first, then each -// light's position/radius/intensity followed by its colour. -const lightUniforms: Record = { u_ambient: [0.12, 0.12, 0.16, 0] }; -for (let index = 0; index < LIGHT_COUNT; index++) { - lightUniforms[`u_light${index}`] = [0, 0, 1, 0]; - lightUniforms[`u_lightColor${index}`] = [1, 1, 1, 0]; -} - -const litMaterial = new SpriteMaterial({ - shader: new ShaderSource({ glsl: { vertex: vertexGlsl, fragment: fragmentGlsl }, wgsl: fragmentWgsl }), - uniforms: lightUniforms, - textures: { u_normalMap: normalTexture }, -}); - const lightColors = [new Color(255, 180, 120), new Color(120, 180, 255), new Color(160, 255, 160), new Color(255, 120, 200)]; class NormalMappedSpritesScene extends Scene { private layer!: Container; + private lighting!: LightingSystem; + private material!: LitSpriteMaterial; + private lights!: PointLight[]; private tiles!: { sprite: Sprite; spin: number }[]; private markers!: Sprite[]; private elapsed = 0; @@ -179,6 +93,19 @@ class NormalMappedSpritesScene extends Scene { const { width, height } = this.app; this.layer = new Container(); + this.lighting = new LightingSystem({ maxLights: LIGHT_COUNT, ambient: new Color(30, 30, 40) }); + this.material = new LitSpriteMaterial({ lighting: this.lighting, normalMap: normalTexture }); + + // Scene systems tick after Scene.update(), so the packed light texture + // always describes the frame that is about to be drawn. + this.systems.add(this.lighting); + + this.lights = lightColors.map(color => { + const light = new PointLight({ radius: 320, intensity: 1.4, height: 80, color }); + this.lighting.add(light); + return light; + }); + const columns = 8; const rows = 4; const spacing = 140; @@ -194,7 +121,7 @@ class NormalMappedSpritesScene extends Scene { const sprite = new Sprite(albedoTexture).setAnchor(0.5); sprite.setPosition(originX + column * spacing, originY + row * spacing); sprite.setScale(index % 3 === 0 ? -1 : 1, 1); - sprite.material = litMaterial; + sprite.material = this.material; this.layer.addChild(sprite); this.tiles.push({ sprite, spin: index % 2 === 0 ? 0 : index % 4 === 1 ? 45 : -30 }); } @@ -226,9 +153,7 @@ class NormalMappedSpritesScene extends Scene { const phase = this.elapsed * (0.4 + index * 0.15) + (index * Math.PI) / 2; const x = width / 2 + Math.cos(phase) * (width * 0.36); const y = height / 2 + Math.sin(phase * 1.3) * (height * 0.36); - const color = lightColors[index]!; - litMaterial.uniforms[`u_light${index}`] = [x, y, 320, 1.4]; - litMaterial.uniforms[`u_lightColor${index}`] = [color.r / 255, color.g / 255, color.b / 255, 0]; + this.lights[index]!.setPosition(x, y); this.markers[index]!.setPosition(x, y); } } diff --git a/scripts/release/RELEASING.md b/scripts/release/RELEASING.md index f35230ea6..21dc937b3 100644 --- a/scripts/release/RELEASING.md +++ b/scripts/release/RELEASING.md @@ -156,17 +156,18 @@ followed by its Trusted Publisher config on npmjs.com. ### Open at the time of writing (checked against the registry 2026-08-29) -- `@codexo/exojs-tilemap-physics` is in `LOCKSTEP_PACKAGES` and therefore in - `PUBLISH_ORDER`, but has never been published (npm answers E404). The next - coordinated release would reach it and abort the chain there. +- `@codexo/exojs-tilemap-physics` and `@codexo/exojs-lighting` are in + `LOCKSTEP_PACKAGES` and therefore in `PUBLISH_ORDER`, but neither has ever + been published (npm answers E404). The next coordinated release would reach + them and abort the chain there. - **Bootstrap it as part of that release, not before it:** run `release:cut` + **Bootstrap each as part of that release, not before it:** run `release:cut` first so the package carries the release version, then - `pnpm release:bootstrap @codexo/exojs-tilemap-physics --execute`, then - register its trusted publisher, and only then run the coordinated publish - - which skips it as already-published and publishes everything else with - provenance. Bootstrapping it earlier publishes a version (today `0.15.2`) - that no release will ever correspond to. + `pnpm release:bootstrap --execute`, then register its trusted + publisher, and only then run the coordinated publish - which skips it as + already-published and publishes everything else with provenance. + Bootstrapping earlier publishes a version that no release will ever + correspond to. - `create-exo-app` and `@codexo/exojs-build` **are** published, both at `0.1.0`, which is what their `package.json` says. They need no bootstrap; their next diff --git a/site/src/content/api/lighting-system-options.json b/site/src/content/api/lighting-system-options.json new file mode 100644 index 000000000..88050d7d3 --- /dev/null +++ b/site/src/content/api/lighting-system-options.json @@ -0,0 +1,100 @@ +{ + "title": "LightingSystemOptions", + "description": "Construction options for LightingSystem.", + "symbol": "LightingSystemOptions", + "kind": "interface", + "subsystem": "lighting", + "importPath": "@codexo/exojs-lighting", + "tier": "stable", + "memberCount": 2, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 2, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Construction options for LightingSystem." + ], + "importLine": "import { LightingSystemOptions } from '@codexo/exojs-lighting'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "ambient", + "signature": "ambient?: Color", + "signatureTokens": [ + { + "text": "ambient", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Color", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Baseline colour every lit fragment receives regardless of any light, as a multiplier on the albedo (255 per channel means \"unlit areas keep their full albedo\"). Stored by reference and re-read on every commit. Defaults to a dim neutral blue-grey." + }, + { + "name": "maxLights", + "signature": "maxLights?: number", + "signatureTokens": [ + { + "text": "maxLights", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Lights the light texture is sized for. Fixed at construction; lights registered beyond it are ignored by LightingSystem.commit until a registered one is removed. Defaults to 64." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-lighting/src/LightingSystem.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-lighting/src/LightingSystem.ts" + } + } + ], + "sourcePath": "packages/exojs-lighting/src/LightingSystem.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-lighting/src/LightingSystem.ts" +} diff --git a/site/src/content/api/lighting-system.json b/site/src/content/api/lighting-system.json new file mode 100644 index 000000000..f4dba6923 --- /dev/null +++ b/site/src/content/api/lighting-system.json @@ -0,0 +1,463 @@ +{ + "title": "LightingSystem", + "description": "Collects PointLights and publishes them to shaders as a single floating-point data texture. The texture is `rgba32f`, `maxLights + 1` texels wide and 2 rows tall: | column | row 0 | row 1 | | -------- | ------------------------------ | ---------------------------------- | | `0` | `(activeLightCount, 0, 0, 0)` | `(ambientR, ambientG, ambientB, 0)` | | `i + 1` | `(x, y, radius, intensity)` | `(r, g, b, height)` | Colour channels are normalized to `0..1`. Because the light count and the ambient term travel in the texture as well, a material that samples it needs no per-frame uniform update, and any number of materials can share one system. # Lifecycle `LightingSystem` is a `System`: register it with the registry that ticks *after* the code that moves the lights, so the packed texture describes the frame that is about to be drawn. ```ts const lighting = new LightingSystem({ ambient: new Color(30, 30, 45) }); scene.systems.add(lighting); // scene systems update after Scene.update() ``` `app.systems` runs its update phase *before* the active scene's, so a system registered there sees lights that scene code has not moved yet. Register on the scene, or call commit yourself at the point that suits the app. The system owns its light texture and destroys it in destroy; the lights themselves are plain data and are not owned.", + "symbol": "LightingSystem", + "kind": "class", + "subsystem": "lighting", + "importPath": "@codexo/exojs-lighting", + "tier": "stable", + "memberCount": 12, + "counts": { + "constructors": 1, + "methods": 6, + "properties": 5, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Collects PointLights and publishes them to shaders as a single floating-point data texture.", + "The texture is `rgba32f`, `maxLights + 1` texels wide and 2 rows tall:", + "| column | row 0 | row 1 | | -------- | ------------------------------ | ---------------------------------- | | `0` | `(activeLightCount, 0, 0, 0)` | `(ambientR, ambientG, ambientB, 0)` | | `i + 1` | `(x, y, radius, intensity)` | `(r, g, b, height)` |", + "Colour channels are normalized to `0..1`. Because the light count and the ambient term travel in the texture as well, a material that samples it needs no per-frame uniform update, and any number of materials can share one system.", + "# Lifecycle", + "`LightingSystem` is a `System`: register it with the registry that ticks *after* the code that moves the lights, so the packed texture describes the frame that is about to be drawn.", + "```ts const lighting = new LightingSystem({ ambient: new Color(30, 30, 45) });", + "scene.systems.add(lighting); // scene systems update after Scene.update() ```", + "`app.systems` runs its update phase *before* the active scene's, so a system registered there sees lights that scene code has not moved yet. Register on the scene, or call commit yourself at the point that suits the app.", + "The system owns its light texture and destroys it in destroy; the lights themselves are plain data and are not owned." + ], + "importLine": "import { LightingSystem } from '@codexo/exojs-lighting'", + "sourceLink": null + }, + { + "id": "constructors", + "title": "Constructors", + "members": [ + { + "name": "new", + "signature": "new(options: LightingSystemOptions): LightingSystem", + "signatureTokens": [ + { + "text": "new", + "kind": "keyword" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LightingSystemOptions", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LightingSystem", + "kind": "type" + } + ], + "params": [ + { + "name": "options", + "type": "LightingSystemOptions", + "optional": false + } + ], + "returnType": "LightingSystem", + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "add", + "signature": "add(light: PointLight): this", + "signatureTokens": [ + { + "text": "add", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "light", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "PointLight", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "light", + "type": "PointLight", + "optional": false + } + ], + "returnType": "this", + "description": "Register a light. Registering the same light twice shades it twice." + }, + { + "name": "clear", + "signature": "clear(): this", + "signatureTokens": [ + { + "text": "clear", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [], + "returnType": "this", + "description": "Unregister every light." + }, + { + "name": "commit", + "signature": "commit(): this", + "signatureTokens": [ + { + "text": "commit", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [], + "returnType": "this", + "description": "Pack the registered lights and the ambient term into the light texture and mark it for upload. Lights are plain mutable data, so there is nothing to observe: every call rewrites the whole header and light range unconditionally. Surplus lights beyond maxLights are skipped." + }, + { + "name": "destroy", + "signature": "destroy(): void", + "signatureTokens": [ + { + "text": "destroy", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [], + "returnType": "void", + "description": "Release the light texture. The registered lights are untouched." + }, + { + "name": "remove", + "signature": "remove(light: PointLight): boolean", + "signatureTokens": [ + { + "text": "remove", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "light", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "PointLight", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [ + { + "name": "light", + "type": "PointLight", + "optional": false + } + ], + "returnType": "boolean", + "description": "Unregister a light. Returns false when it was not registered." + }, + { + "name": "update", + "signature": "update(): void", + "signatureTokens": [ + { + "text": "update", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [], + "returnType": "void", + "description": "System update phase - equivalent to commit." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "ambient", + "signature": "ambient: Color", + "signatureTokens": [ + { + "text": "ambient", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Color", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Baseline colour applied to every lit fragment. Mutable; re-read on each commit." + }, + { + "name": "maxLights", + "signature": "maxLights: number", + "signatureTokens": [ + { + "text": "maxLights", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Lights the texture is sized for." + }, + { + "name": "activeLightCount", + "signature": "activeLightCount: number", + "signatureTokens": [ + { + "text": "activeLightCount", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Lights the last commit actually published - min(lights.length, maxLights)." + }, + { + "name": "lights", + "signature": "lights: readonly PointLight[]", + "signatureTokens": [ + { + "text": "lights", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "PointLight", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Currently registered lights, in registration order." + }, + { + "name": "lightTexture", + "signature": "lightTexture: DataTexture", + "signatureTokens": [ + { + "text": "lightTexture", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "DataTexture", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Rgba32F", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "The packed light texture, to be bound as a material texture. Its identity is stable for the system's lifetime." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-lighting/src/LightingSystem.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-lighting/src/LightingSystem.ts" + } + } + ], + "sourcePath": "packages/exojs-lighting/src/LightingSystem.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-lighting/src/LightingSystem.ts" +} diff --git a/site/src/content/api/lit-sprite-material-options.json b/site/src/content/api/lit-sprite-material-options.json new file mode 100644 index 000000000..76ee3d574 --- /dev/null +++ b/site/src/content/api/lit-sprite-material-options.json @@ -0,0 +1,150 @@ +{ + "title": "LitSpriteMaterialOptions", + "description": "Construction options for LitSpriteMaterial.", + "symbol": "LitSpriteMaterialOptions", + "kind": "interface", + "subsystem": "lighting", + "importPath": "@codexo/exojs-lighting", + "tier": "stable", + "memberCount": 4, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 4, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Construction options for LitSpriteMaterial." + ], + "importLine": "import { LitSpriteMaterialOptions } from '@codexo/exojs-lighting'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "blendMode", + "signature": "blendMode?: BlendModes", + "signatureTokens": [ + { + "text": "blendMode", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "BlendModes", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Compositing blend mode. Defaults to BlendModes.Normal." + }, + { + "name": "lighting", + "signature": "lighting: LightingSystem", + "signatureTokens": [ + { + "text": "lighting", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LightingSystem", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "System whose light texture this material shades against. Not owned." + }, + { + "name": "normalMap", + "signature": "normalMap: Texture", + "signatureTokens": [ + { + "text": "normalMap", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Texture", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Tangent-space normal map, sampled with the sprite's own UVs: it must be laid out exactly like the base atlas, frame for frame. +x points right and +y points down the texture, matching the sprite's local axes." + }, + { + "name": "sampler", + "signature": "sampler?: SamplerOptions | null", + "signatureTokens": [ + { + "text": "sampler", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "SamplerOptions", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Filter/wrap override for the base texture, or null to inherit it. Defaults to null." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-lighting/src/LitSpriteMaterial.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-lighting/src/LitSpriteMaterial.ts" + } + } + ], + "sourcePath": "packages/exojs-lighting/src/LitSpriteMaterial.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-lighting/src/LitSpriteMaterial.ts" +} diff --git a/site/src/content/api/lit-sprite-material.json b/site/src/content/api/lit-sprite-material.json new file mode 100644 index 000000000..17f2798d2 --- /dev/null +++ b/site/src/content/api/lit-sprite-material.json @@ -0,0 +1,909 @@ +{ + "title": "LitSpriteMaterial", + "description": "A SpriteMaterial that shades sprites with a tangent-space normal map against the point lights of a LightingSystem. Lighting happens in the sprite's own fragment stage, so a lit sprite costs no extra pass and no extra draw call: every sprite sharing this material and a base-texture slot stays in one batch. The shaded result is `albedo * (ambient + sum over lights)`, with each light falling off quadratically to nothing at its radius. # One normal map per material The normal map is a material binding, not a per-sprite one, so all sprites drawn with a given `LitSpriteMaterial` must share its layout - in practice one material per atlas. Sprites from a second atlas need a second material, which breaks the batch at the material boundary. Both materials can shade against the same `LightingSystem`. # Ownership The material owns neither the lighting system nor the textures. SpriteMaterial.destroy releases only the GPU resources cached against this material. ```ts const lighting = new LightingSystem(); const material = new LitSpriteMaterial({ lighting, normalMap }); scene.systems.add(lighting); lighting.add(new PointLight({ x: 400, y: 300, radius: 320 })); sprite.material = material; ```", + "symbol": "LitSpriteMaterial", + "kind": "class", + "subsystem": "lighting", + "importPath": "@codexo/exojs-lighting", + "tier": "stable", + "memberCount": 17, + "counts": { + "constructors": 1, + "methods": 6, + "properties": 10, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "A SpriteMaterial that shades sprites with a tangent-space normal map against the point lights of a LightingSystem.", + "Lighting happens in the sprite's own fragment stage, so a lit sprite costs no extra pass and no extra draw call: every sprite sharing this material and a base-texture slot stays in one batch. The shaded result is `albedo * (ambient + sum over lights)`, with each light falling off quadratically to nothing at its radius.", + "# One normal map per material", + "The normal map is a material binding, not a per-sprite one, so all sprites drawn with a given `LitSpriteMaterial` must share its layout - in practice one material per atlas. Sprites from a second atlas need a second material, which breaks the batch at the material boundary. Both materials can shade against the same `LightingSystem`.", + "# Ownership", + "The material owns neither the lighting system nor the textures. SpriteMaterial.destroy releases only the GPU resources cached against this material.", + "```ts const lighting = new LightingSystem(); const material = new LitSpriteMaterial({ lighting, normalMap });", + "scene.systems.add(lighting); lighting.add(new PointLight({ x: 400, y: 300, radius: 320 })); sprite.material = material; ```" + ], + "importLine": "import { LitSpriteMaterial } from '@codexo/exojs-lighting'", + "sourceLink": null + }, + { + "id": "constructors", + "title": "Constructors", + "members": [ + { + "name": "new", + "signature": "new(options: LitSpriteMaterialOptions): LitSpriteMaterial", + "signatureTokens": [ + { + "text": "new", + "kind": "keyword" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LitSpriteMaterialOptions", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LitSpriteMaterial", + "kind": "type" + } + ], + "params": [ + { + "name": "options", + "type": "LitSpriteMaterialOptions", + "optional": false + } + ], + "returnType": "LitSpriteMaterial", + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "_onDispose", + "signature": "_onDispose(callback: () => void): void", + "signatureTokens": [ + { + "text": "_onDispose", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "callback", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ") => ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [ + { + "name": "callback", + "type": "() => void", + "optional": false + } + ], + "returnType": "void", + "description": "Hook for renderers to register a per-material-instance cleanup callback (release compiled program, pipeline, or bind groups). The callback fires on destroy; renderers MUST also tolerate the material being garbage-collected without destroy ever being called. Part of the renderer SDK contract for extension renderers." + }, + { + "name": "destroy", + "signature": "destroy(): void", + "signatureTokens": [ + { + "text": "destroy", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [], + "returnType": "void", + "description": "Release GPU resources cached against this material on every backend that has compiled it. Safe to call multiple times. After destroy, the material can still be re-used - renderers recompile on next draw - but typical usage is to drop the reference." + }, + { + "name": "setTexture", + "signature": "setTexture(name: string, texture: RenderTexture | Texture): this", + "signatureTokens": [ + { + "text": "setTexture", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "name", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "texture", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "RenderTexture", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "Texture", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "name", + "type": "string", + "optional": false + }, + { + "name": "texture", + "type": "RenderTexture | Texture", + "optional": false + } + ], + "returnType": "this", + "description": "Replace the texture behind a declared slot, returning this for chaining." + }, + { + "name": "setUniform", + "signature": "setUniform(name: string, value: UniformValue): this", + "signatureTokens": [ + { + "text": "setUniform", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "name", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "value", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "UniformValue", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "name", + "type": "string", + "optional": false + }, + { + "name": "value", + "type": "UniformValue", + "optional": false + } + ], + "returnType": "this", + "description": "Replace a declared uniform value, returning this for chaining. Unknown names and scalar↔texture kind changes are rejected." + }, + { + "name": "from", + "signature": "from(source: ShaderSource, options?: Omit): SpriteMaterial", + "signatureTokens": [ + { + "text": "from", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "ShaderSource", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Omit", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "MaterialOptions", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "\"shader\"", + "kind": "keyword" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "SpriteMaterial", + "kind": "type" + } + ], + "params": [ + { + "name": "source", + "type": "ShaderSource", + "optional": false + }, + { + "name": "options", + "type": "Omit", + "optional": true + } + ], + "returnType": "SpriteMaterial", + "description": "Build a SpriteMaterial from an existing ShaderSource. Equivalent to new SpriteMaterial({ shader, ...options })." + }, + { + "name": "from", + "signature": "from(glslVertex: string, glslFragment: string, options?: { blendMode?: BlendModes; sampler?: SamplerOptions | null; uniforms?: Record; wgsl?: string }): SpriteMaterial", + "signatureTokens": [ + { + "text": "from", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "glslVertex", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "glslFragment", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "{ ", + "kind": "punctuation" + }, + { + "text": "blendMode", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "BlendModes", + "kind": "type" + }, + { + "text": "; ", + "kind": "punctuation" + }, + { + "text": "sampler", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "SamplerOptions", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + }, + { + "text": "; ", + "kind": "punctuation" + }, + { + "text": "uniforms", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Record", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "UniformValue", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": "; ", + "kind": "punctuation" + }, + { + "text": "wgsl", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": " }", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "SpriteMaterial", + "kind": "type" + } + ], + "params": [ + { + "name": "glslVertex", + "type": "string", + "optional": false + }, + { + "name": "glslFragment", + "type": "string", + "optional": false + }, + { + "name": "options", + "type": "{ blendMode?: BlendModes; sampler?: SamplerOptions | null; uniforms?: Record; wgsl?: string }", + "optional": true + } + ], + "returnType": "SpriteMaterial", + "description": "Build a SpriteMaterial from raw GLSL vertex and fragment source strings. Wraps them in a new ShaderSource; pass options.wgsl to also cover the WebGPU backend." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "blendMode", + "signature": "blendMode: BlendModes", + "signatureTokens": [ + { + "text": "blendMode", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "BlendModes", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Compositing blend mode applied when drawing with this material." + }, + { + "name": "lighting", + "signature": "lighting: LightingSystem", + "signatureTokens": [ + { + "text": "lighting", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LightingSystem", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "The system this material shades against." + }, + { + "name": "sampler", + "signature": "sampler: SamplerOptions | null", + "signatureTokens": [ + { + "text": "sampler", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "SamplerOptions", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Filter/wrap override for the drawable's base texture, or null to inherit it." + }, + { + "name": "shader", + "signature": "shader: ShaderSource", + "signatureTokens": [ + { + "text": "shader", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "ShaderSource", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "GLSL/WGSL source pair backing this material." + }, + { + "name": "target", + "signature": "target: \"sprite\"", + "signatureTokens": [ + { + "text": "target", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "\"sprite\"", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Which drawable class this material can serve; renderers check compatibility." + }, + { + "name": "bindKey", + "signature": "bindKey: number", + "signatureTokens": [ + { + "text": "bindKey", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Stable bind key: identical ⇒ same bindings (textures unchanged). Derived from this material's identity, base-texture sampler override, and the identities of its bound textures. Changes when a texture is swapped or sampler state changes; drives bind-group/slot reuse." + }, + { + "name": "normalMap", + "signature": "normalMap: Texture", + "signatureTokens": [ + { + "text": "normalMap", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Texture", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "The bound normal map. Assigning a replacement takes effect on the next draw." + }, + { + "name": "pipelineKey", + "signature": "pipelineKey: number", + "signatureTokens": [ + { + "text": "pipelineKey", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Stable pipeline key: identical ⇒ same GPU pipeline/program can be used. Derived from shader identity and blend mode, and is independent of the owning material instance so identically configured materials share a pipeline. Drives grouping and the pipeline cache." + }, + { + "name": "textures", + "signature": "textures: Record", + "signatureTokens": [ + { + "text": "textures", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Record", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "RenderTexture", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "Texture", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Live identities behind the fixed named texture slots." + }, + { + "name": "uniforms", + "signature": "uniforms: Record", + "signatureTokens": [ + { + "text": "uniforms", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Record", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "UniformValue", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Live user uniform values. Construction declares the fixed set of names and each name's scalar/texture kind; existing values can be replaced between frames and typed arrays can be mutated in place. material.uniforms.u_time = performance.now() / 1000; material.uniforms.u_color = [1, 0.5, 0, 1];" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-lighting/src/LitSpriteMaterial.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-lighting/src/LitSpriteMaterial.ts" + } + } + ], + "sourcePath": "packages/exojs-lighting/src/LitSpriteMaterial.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-lighting/src/LitSpriteMaterial.ts" +} diff --git a/site/src/content/api/point-light-options.json b/site/src/content/api/point-light-options.json new file mode 100644 index 000000000..ec98bb903 --- /dev/null +++ b/site/src/content/api/point-light-options.json @@ -0,0 +1,200 @@ +{ + "title": "PointLightOptions", + "description": "Construction options for PointLight. Every field is also mutable afterwards.", + "symbol": "PointLightOptions", + "kind": "interface", + "subsystem": "lighting", + "importPath": "@codexo/exojs-lighting", + "tier": "stable", + "memberCount": 6, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 6, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Construction options for PointLight. Every field is also mutable afterwards." + ], + "importLine": "import { PointLightOptions } from '@codexo/exojs-lighting'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "color", + "signature": "color?: Color", + "signatureTokens": [ + { + "text": "color", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Color", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Light colour. The instance is stored by reference, so mutating it after construction is picked up on the next LightingSystem.commit. Defaults to opaque white; alpha is ignored." + }, + { + "name": "height", + "signature": "height?: number", + "signatureTokens": [ + { + "text": "height", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Height above the sprite plane, in pixels. Drives how grazing the light direction is: small values rake across the surface and exaggerate the normal map, large values flatten it out. Defaults to 64." + }, + { + "name": "intensity", + "signature": "intensity?: number", + "signatureTokens": [ + { + "text": "intensity", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Linear brightness multiplier. Defaults to 1." + }, + { + "name": "radius", + "signature": "radius?: number", + "signatureTokens": [ + { + "text": "radius", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Distance in pixels at which the light contributes nothing. Defaults to 256." + }, + { + "name": "x", + "signature": "x?: number", + "signatureTokens": [ + { + "text": "x", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "World-space x, in pixels." + }, + { + "name": "y", + "signature": "y?: number", + "signatureTokens": [ + { + "text": "y", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "World-space y, in pixels." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-lighting/src/PointLight.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-lighting/src/PointLight.ts" + } + } + ], + "sourcePath": "packages/exojs-lighting/src/PointLight.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-lighting/src/PointLight.ts" +} diff --git a/site/src/content/api/point-light.json b/site/src/content/api/point-light.json new file mode 100644 index 000000000..2865b0fd9 --- /dev/null +++ b/site/src/content/api/point-light.json @@ -0,0 +1,310 @@ +{ + "title": "PointLight", + "description": "A world-space point light. Plain mutable data - no scene node, no transform, no parenting. Move one by assigning x/y (or through setPosition) and the change takes effect on the next LightingSystem.commit. A light contributes to shading only while it is registered with a LightingSystem.", + "symbol": "PointLight", + "kind": "class", + "subsystem": "lighting", + "importPath": "@codexo/exojs-lighting", + "tier": "stable", + "memberCount": 8, + "counts": { + "constructors": 1, + "methods": 1, + "properties": 6, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "A world-space point light.", + "Plain mutable data - no scene node, no transform, no parenting. Move one by assigning x/y (or through setPosition) and the change takes effect on the next LightingSystem.commit. A light contributes to shading only while it is registered with a LightingSystem." + ], + "importLine": "import { PointLight } from '@codexo/exojs-lighting'", + "sourceLink": null + }, + { + "id": "constructors", + "title": "Constructors", + "members": [ + { + "name": "new", + "signature": "new(options: PointLightOptions): PointLight", + "signatureTokens": [ + { + "text": "new", + "kind": "keyword" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "PointLightOptions", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "PointLight", + "kind": "type" + } + ], + "params": [ + { + "name": "options", + "type": "PointLightOptions", + "optional": false + } + ], + "returnType": "PointLight", + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "setPosition", + "signature": "setPosition(x: number, y: number): this", + "signatureTokens": [ + { + "text": "setPosition", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "this", + "description": "Move the light, returning this for chaining." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "color", + "signature": "color: Color", + "signatureTokens": [ + { + "text": "color", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Color", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "height", + "signature": "height: number", + "signatureTokens": [ + { + "text": "height", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "intensity", + "signature": "intensity: number", + "signatureTokens": [ + { + "text": "intensity", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "radius", + "signature": "radius: number", + "signatureTokens": [ + { + "text": "radius", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "x", + "signature": "x: number", + "signatureTokens": [ + { + "text": "x", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "y", + "signature": "y: number", + "signatureTokens": [ + { + "text": "y", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-lighting/src/PointLight.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-lighting/src/PointLight.ts" + } + } + ], + "sourcePath": "packages/exojs-lighting/src/PointLight.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-lighting/src/PointLight.ts" +} diff --git a/test/rendering/browser/webgl2-lighting.test.ts b/test/rendering/browser/webgl2-lighting.test.ts new file mode 100644 index 000000000..6b9cbece9 --- /dev/null +++ b/test/rendering/browser/webgl2-lighting.test.ts @@ -0,0 +1,180 @@ +/** + * WebGL2 browser coverage for `@codexo/exojs-lighting`: the packed light + * texture reaches the fragment stage through the custom sprite-material path, + * the distance falloff is visible in the framebuffer, and a mirrored instance + * is shaded exactly like an unmirrored one. + * + * Run via: pnpm test:browser:webgl2 + */ + +import { LightingSystem, LitSpriteMaterial, PointLight } from '@codexo/exojs-lighting'; + +import type { Application } from '#core/Application'; +import { Color } from '#core/Color'; +import { Container } from '#rendering/Container'; +import { Sprite } from '#rendering/sprite/Sprite'; +import { Texture } from '#rendering/texture/Texture'; +import { WebGl2Backend } from '#rendering/webgl2/WebGl2Backend'; + +import { readWebGl2Pixel } from './_backendSetup'; +import { wireCoreRenderers } from './_coreRenderers'; + +const canvasSize = 64; + +const createBackend = async (): Promise => { + const canvas = document.createElement('canvas'); + + canvas.width = canvasSize; + canvas.height = canvasSize; + + const app = { + canvas, + options: { + clearColor: Color.black, + canvas: { width: canvasSize, height: canvasSize }, + rendering: { + debug: false, + webglAttributes: { antialias: false, preserveDrawingBuffer: true, stencil: false, depth: false }, + spriteRendererBatchSize: 1024, + particleRendererBatchSize: 1024, + }, + }, + } as unknown as Application; + + const backend = new WebGl2Backend(app); + + await backend.initialize(); + wireCoreRenderers(backend, app.options.rendering); + + return backend; +}; + +/** Opaque white albedo, so the framebuffer reads back the light term alone. */ +const createAlbedo = (): Texture => { + const source = document.createElement('canvas'); + + source.width = 4; + source.height = 4; + + const context = source.getContext('2d'); + + if (!context) throw new Error('2D context is required to create test textures.'); + + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, 4, 4); + + return new Texture(source); +}; + +/** Flat normal map: every texel is (0, 0, 1), so mirroring must not change shading. */ +const createFlatNormalMap = (): Texture => { + const source = document.createElement('canvas'); + + source.width = 4; + source.height = 4; + + const context = source.getContext('2d'); + + if (!context) throw new Error('2D context is required to create test textures.'); + + context.fillStyle = 'rgb(128, 128, 255)'; + context.fillRect(0, 0, 4, 4); + + return new Texture(source); +}; + +describe('lighting WebGL2 browser', () => { + test('shades a batch by distance and treats a mirrored sprite identically', async () => { + const backend = await createBackend(); + const albedo = createAlbedo(); + const normalMap = createFlatNormalMap(); + const lighting = new LightingSystem({ maxLights: 4, ambient: Color.black }); + const material = new LitSpriteMaterial({ lighting, normalMap }); + const root = new Container(); + const upright = new Sprite(albedo); + const mirrored = new Sprite(albedo); + + // Two 24x24 quads either side of a light at (32, 32): the upright one spans + // x 4..28, the mirrored one (negative x scale) spans x 36..60. + upright.material = material; + upright.setPosition(4, 20).setScale(24, 24); + mirrored.material = material; + mirrored.setPosition(60, 20).setScale(-24, 24); + root.addChild(upright); + root.addChild(mirrored); + + lighting.add(new PointLight({ x: 32, y: 32, radius: 64, intensity: 1, height: 20 })); + lighting.commit(); + + try { + backend.resetStats(); + backend.clear(Color.black); + root.render(backend); + backend.flush(); + + const near = readWebGl2Pixel(backend, 26, 32); + const far = readWebGl2Pixel(backend, 6, 32); + // Mirror of x=26 about the light at x=32: both texel centres sit 5.5 px away. + const mirroredNear = readWebGl2Pixel(backend, 37, 32); + + // One material, one base texture: both quads stay in a single batch. + expect(backend.stats.drawCalls).toBe(1); + expect(near[0]).toBeGreaterThan(150); + expect(far[0]).toBeLessThan(100); + expect(near[0] - far[0]).toBeGreaterThan(60); + expect(Math.abs(mirroredNear[0] - near[0])).toBeLessThanOrEqual(2); + } finally { + root.destroy(); + material.destroy(); + lighting.destroy(); + normalMap.destroy(); + albedo.destroy(); + backend.destroy(); + } + }); + + test('an unlit scene falls back to the ambient term and a committed light lights it', async () => { + const backend = await createBackend(); + const albedo = createAlbedo(); + const normalMap = createFlatNormalMap(); + const lighting = new LightingSystem({ maxLights: 4, ambient: new Color(64, 64, 64) }); + const material = new LitSpriteMaterial({ lighting, normalMap }); + const root = new Container(); + const sprite = new Sprite(albedo); + + sprite.material = material; + sprite.setPosition(16, 16).setScale(32, 32); + root.addChild(sprite); + + const render = (): void => { + backend.resetStats(); + backend.clear(Color.black); + root.render(backend); + backend.flush(); + }; + + try { + render(); + + const ambientOnly = readWebGl2Pixel(backend, 32, 32); + + expect(ambientOnly[0]).toBeGreaterThan(50); + expect(ambientOnly[0]).toBeLessThan(80); + + lighting.add(new PointLight({ x: 32, y: 32, radius: 64, intensity: 1, height: 16 })); + lighting.commit(); + render(); + + const lit = readWebGl2Pixel(backend, 32, 32); + + expect(lit[0]).toBeGreaterThan(ambientOnly[0] + 100); + } finally { + root.destroy(); + material.destroy(); + lighting.destroy(); + normalMap.destroy(); + albedo.destroy(); + backend.destroy(); + } + }); +}); diff --git a/test/rendering/browser/webgpu-lighting.test.ts b/test/rendering/browser/webgpu-lighting.test.ts new file mode 100644 index 000000000..c95726bcf --- /dev/null +++ b/test/rendering/browser/webgpu-lighting.test.ts @@ -0,0 +1,205 @@ +/** + * WebGPU browser coverage for `@codexo/exojs-lighting`: the `rgba32f` light + * texture binds through the custom sprite-material group(2) layout without a + * validation error, the distance falloff is visible in the framebuffer, and a + * mirrored instance is shaded exactly like an unmirrored one. + * + * Run via: pnpm test:browser:webgpu + */ + +import { LightingSystem, LitSpriteMaterial, PointLight } from '@codexo/exojs-lighting'; + +import type { Application } from '#core/Application'; +import { Color } from '#core/Color'; +import { Container } from '#rendering/Container'; +import { Sprite } from '#rendering/sprite/Sprite'; +import { Texture } from '#rendering/texture/Texture'; +import { WebGpuBackend } from '#rendering/webgpu/WebGpuBackend'; + +import { readWebGpuPixels } from './_backendSetup'; +import { wireCoreRenderers } from './_coreRenderers'; +import { getBackendDevice } from './webgpu-test-helpers'; + +const canvasSize = 64; + +const makeApp = (canvas: HTMLCanvasElement): Application => + ({ + canvas, + options: { + canvas: { width: canvasSize, height: canvasSize }, + clearColor: Color.black, + }, + }) as unknown as Application; + +const createBackend = async (): Promise => { + const canvas = document.createElement('canvas'); + + canvas.width = canvasSize; + canvas.height = canvasSize; + + const backend = new WebGpuBackend(makeApp(canvas)); + + await backend.initialize(); + wireCoreRenderers(backend); + + return backend; +}; + +const createSolidTexture = (fillStyle: string): Texture => { + const source = document.createElement('canvas'); + + source.width = 4; + source.height = 4; + + const context = source.getContext('2d'); + + if (!context) throw new Error('2D context is required to create test textures.'); + + context.fillStyle = fillStyle; + context.fillRect(0, 0, 4, 4); + + return new Texture(source); +}; + +/** Opaque white albedo, so the framebuffer reads back the light term alone. */ +const createAlbedo = (): Texture => createSolidTexture('#ffffff'); + +/** Flat normal map: every texel is (0, 0, 1), so mirroring must not change shading. */ +const createFlatNormalMap = (): Texture => createSolidTexture('rgb(128, 128, 255)'); + +describe('lighting WebGPU browser', () => { + test('shades a batch by distance and treats a mirrored sprite identically', async ctx => { + const backend = await createBackend(); + const device = getBackendDevice(backend); + const albedo = createAlbedo(); + const normalMap = createFlatNormalMap(); + const lighting = new LightingSystem({ maxLights: 4, ambient: Color.black }); + const material = new LitSpriteMaterial({ lighting, normalMap }); + const root = new Container(); + const upright = new Sprite(albedo); + const mirrored = new Sprite(albedo); + + // Two 24x24 quads either side of a light at (32, 32): the upright one spans + // x 4..28, the mirrored one (negative x scale) spans x 36..60. + upright.material = material; + upright.setPosition(4, 20).setScale(24, 24); + mirrored.material = material; + mirrored.setPosition(60, 20).setScale(-24, 24); + root.addChild(upright); + root.addChild(mirrored); + + lighting.add(new PointLight({ x: 32, y: 32, radius: 64, intensity: 1, height: 20 })); + lighting.commit(); + + const cleanup = (): void => { + root.destroy(); + material.destroy(); + lighting.destroy(); + normalMap.destroy(); + albedo.destroy(); + backend.destroy(); + }; + + let validationError: GPUError | null; + + device.pushErrorScope('validation'); + + try { + backend.resetStats(); + backend.clear(Color.black); + root.render(backend); + backend.flush(); + validationError = await device.popErrorScope(); + await device.queue.onSubmittedWorkDone(); + } catch (error) { + if (error instanceof DOMException && (error.name === 'OperationError' || error.name === 'AbortError')) { + cleanup(); + // eslint-disable-next-line vitest/no-disabled-tests -- intentional runtime guard: the software WebGPU adapter can drop the device mid-test + ctx.skip('WebGPU device lost mid-test — unstable software adapter'); + + return; + } + + throw error; + } + + try { + const readPixel = readWebGpuPixels(backend, canvasSize); + const near = readPixel(26, 32); + const far = readPixel(6, 32); + // Mirror of x=26 about the light at x=32: both texel centres sit 5.5 px away. + const mirroredNear = readPixel(37, 32); + + expect(validationError).toBeNull(); + // One material, one base texture: both quads stay in a single batch. + expect(backend.stats.drawCalls).toBe(1); + expect(near[0]).toBeGreaterThan(150); + expect(far[0]).toBeLessThan(100); + expect(near[0] - far[0]).toBeGreaterThan(60); + expect(Math.abs(mirroredNear[0] - near[0])).toBeLessThanOrEqual(2); + } finally { + cleanup(); + } + }); + + test('an unlit scene falls back to the ambient term and a committed light lights it', async ctx => { + const backend = await createBackend(); + const device = getBackendDevice(backend); + const albedo = createAlbedo(); + const normalMap = createFlatNormalMap(); + const lighting = new LightingSystem({ maxLights: 4, ambient: new Color(64, 64, 64) }); + const material = new LitSpriteMaterial({ lighting, normalMap }); + const root = new Container(); + const sprite = new Sprite(albedo); + + sprite.material = material; + sprite.setPosition(16, 16).setScale(32, 32); + root.addChild(sprite); + + const cleanup = (): void => { + root.destroy(); + material.destroy(); + lighting.destroy(); + normalMap.destroy(); + albedo.destroy(); + backend.destroy(); + }; + + const render = async (): Promise => { + backend.resetStats(); + backend.clear(Color.black); + root.render(backend); + backend.flush(); + await device.queue.onSubmittedWorkDone(); + }; + + try { + await render(); + + const ambientOnly = readWebGpuPixels(backend, canvasSize)(32, 32); + + expect(ambientOnly[0]).toBeGreaterThan(50); + expect(ambientOnly[0]).toBeLessThan(80); + + lighting.add(new PointLight({ x: 32, y: 32, radius: 64, intensity: 1, height: 16 })); + lighting.commit(); + await render(); + + const lit = readWebGpuPixels(backend, canvasSize)(32, 32); + + expect(lit[0]).toBeGreaterThan(ambientOnly[0] + 100); + } catch (error) { + if (error instanceof DOMException && (error.name === 'OperationError' || error.name === 'AbortError')) { + cleanup(); + // eslint-disable-next-line vitest/no-disabled-tests -- intentional runtime guard: the software WebGPU adapter can drop the device mid-test + ctx.skip('WebGPU device lost mid-test — unstable software adapter'); + + return; + } + + throw error; + } finally { + if (!albedo.destroyed) cleanup(); + } + }); +}); From b03c8802543ebe6403f6c738b9c4c7d32d211ab5 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 4 Sep 2026 17:51:53 +0200 Subject: [PATCH 3/3] test(lighting): compile the lit sprite fragment in the real-shader gate The GLSL compile suite globs every package's shader files and requires each to be either half of a program pair or declared standalone. `lit-sprite.frag` is a sprite-material fragment, so it only compiles once the renderer has spliced in the base-texture slot table and `sampleBase()`; the suite now composes it the same way it already composes the text-atlas fragments, and links it against `sprite-material.vert` - which stops being "standalone, fragment comes from the application" now that an in-repo fragment exists for it. Also map `@codexo/exojs-lighting` in test/tsconfig.json so the two lighting browser suites type-check. Claude-Session: https://claude.ai/code/session_01NSrXpH1WyqP7udpWWhHz1i --- .../browser/webgl2-shader-compile.test.ts | 19 ++++++++++++++++--- test/tsconfig.json | 1 + 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/test/rendering/browser/webgl2-shader-compile.test.ts b/test/rendering/browser/webgl2-shader-compile.test.ts index 1355e4ad6..085ffcffa 100644 --- a/test/rendering/browser/webgl2-shader-compile.test.ts +++ b/test/rendering/browser/webgl2-shader-compile.test.ts @@ -17,6 +17,7 @@ import { stripShaderSource } from '@codexo/exojs-build/shader-strip'; import { fillShaderSource } from '#rendering/shader/fillShaderSource'; import { resolveTransformTextureGlsl } from '#rendering/shader/transformTextureLayout'; +import { composeSpriteMaterialFragmentGlsl } from '#rendering/sprite/materialSources'; import { composeTextAtlasFragmentGlsl } from '#rendering/text/atlasTextureSlots'; import { TILE_DIAGONAL_BIT, TILE_ROW_MASK } from '../../../packages/exojs-tilemap/src/tileWord'; @@ -66,8 +67,17 @@ const placeholderValues: Readonly const composeRuntimeSource = (name: string, source: string): string => { const values = placeholderValues[name]; const filled = values ? fillShaderSource(source, values) : source; - - return resolveTransformTextureGlsl(name.startsWith('text-') && name.endsWith('.frag') ? composeTextAtlasFragmentGlsl(filled) : filled); + // A sprite-material fragment is authored without the base-texture slot table + // and `sampleBase()`: the renderer splices those in. `lit-sprite.frag` ships + // from the lighting package and only compiles in that spliced form. + const composed = + name.startsWith('text-') && name.endsWith('.frag') + ? composeTextAtlasFragmentGlsl(filled) + : name === 'lit-sprite.frag' + ? composeSpriteMaterialFragmentGlsl(filled) + : filled; + + return resolveTransformTextureGlsl(composed); }; const shaders: readonly ShaderEntry[] = Object.entries(shaderModules) @@ -118,6 +128,9 @@ const programPairs: ReadonlyArray = [ ['default-vertex.vert', 'drop-shadow.frag'], ['default-vertex.vert', 'lut-3d.frag'], ['default-vertex.vert', 'lut-rgb1d.frag'], + // The custom sprite-material path: the engine owns the vertex stage, and the + // lighting package's lit fragment is the in-repo counterpart it links with. + ['sprite-material.vert', 'lit-sprite.frag'], ]; const referencedShaderFiles = new Set(programPairs.flat()); @@ -129,7 +142,7 @@ const referencedShaderFiles = new Set(programPairs.flat()); // case has no meaning for them. An entry is a claim that the missing half is // the caller's, not that the stage is untested. const standaloneStages: ReadonlyMap = new Map([ - ['sprite-material.vert', 'the custom SpriteMaterial path takes its fragment stage from the application'], + ['lit-sprite.vert', 'placeholder the lighting package hands ShaderSource; the renderer owns the sprite vertex stage and never compiles it'], ]); interface CompiledShader { diff --git a/test/tsconfig.json b/test/tsconfig.json index d891abe6c..4cbf5e769 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -71,6 +71,7 @@ "@codexo/exojs-audio-fx": ["../packages/exojs-audio-fx/src/index.ts"], "@codexo/exojs-aseprite": ["../packages/exojs-aseprite/src/index.ts"], "@codexo/exojs-ldtk": ["../packages/exojs-ldtk/src/index.ts"], + "@codexo/exojs-lighting": ["../packages/exojs-lighting/src/index.ts"], "@codexo/exojs-physics/debug": ["../packages/exojs-physics/src/debug/index.ts"] } },