Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion examples/examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
155 changes: 155 additions & 0 deletions examples/lighting/many-lights.js
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading