@waica/engine 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +24 -0
  2. package/dist/components/animated-sprite.d.ts +6 -0
  3. package/dist/components/animated-sprite.js +15 -1
  4. package/dist/components/light.d.ts +68 -0
  5. package/dist/components/light.js +59 -0
  6. package/dist/components/particle-emitter.d.ts +6 -0
  7. package/dist/components/particle-emitter.js +13 -0
  8. package/dist/components/sprite.d.ts +6 -0
  9. package/dist/components/sprite.js +15 -1
  10. package/dist/components/tilemap.js +6 -0
  11. package/dist/frame-quad.d.ts +21 -0
  12. package/dist/frame-quad.js +50 -0
  13. package/dist/game.d.ts +8 -0
  14. package/dist/game.js +20 -8
  15. package/dist/index.d.ts +11 -1
  16. package/dist/index.js +6 -0
  17. package/dist/light-field.d.ts +78 -0
  18. package/dist/light-field.js +145 -0
  19. package/dist/light-map-material.d.ts +40 -0
  20. package/dist/light-map-material.js +203 -0
  21. package/dist/light-map.d.ts +46 -0
  22. package/dist/light-map.js +132 -0
  23. package/dist/lit-frame.d.ts +48 -0
  24. package/dist/lit-frame.js +167 -0
  25. package/dist/occluder-grid.d.ts +19 -0
  26. package/dist/occluder-grid.js +50 -0
  27. package/dist/particle-batch.js +3 -2
  28. package/dist/post-effects.d.ts +33 -0
  29. package/dist/post-effects.js +48 -0
  30. package/dist/post-pass.d.ts +23 -0
  31. package/dist/post-pass.js +93 -0
  32. package/dist/render-layers.d.ts +18 -0
  33. package/dist/render-layers.js +19 -0
  34. package/dist/render-output.d.ts +7 -0
  35. package/dist/render-output.js +17 -1
  36. package/dist/runtime-inspection.d.ts +5 -0
  37. package/dist/runtime-inspection.js +5 -1
  38. package/dist/runtime-lighting-snapshot.d.ts +41 -0
  39. package/dist/runtime-lighting-snapshot.js +46 -0
  40. package/dist/scene-lighting.d.ts +52 -0
  41. package/dist/scene-lighting.js +107 -0
  42. package/dist/scene-render-options.d.ts +51 -0
  43. package/dist/scene-render-options.js +126 -0
  44. package/dist/scene.d.ts +29 -0
  45. package/dist/sprite-batch.js +5 -1
  46. package/package.json +1 -1
package/README.md CHANGED
@@ -183,6 +183,30 @@ Under batching, the object a sprite adds to its entity is a hidden placement anc
183
183
 
184
184
  How much batching saves depends on how interleaved a scene is: one texture over the whole scene draws in one call, while a top-down scene that alternates many textures by y gains little. Runtime Snapshots are unchanged — batches are not entities, and sprites report the same state either way.
185
185
 
186
+ ## Lighting and Post Effects
187
+
188
+ A scene can be lit (issue #78, ADR 0026). Without lighting and without a Post Effect a scene is drawn exactly as before: one render straight into the canvas, no render target.
189
+
190
+ ```json
191
+ "render": {
192
+ "lighting": { "ambient": { "color": "#5a6a9a", "intensity": 0.3 } },
193
+ "post": {
194
+ "vignette": { "intensity": 0.45, "radius": 0.35 },
195
+ "colorGrade": { "tint": "#ffeedd", "contrast": 1.1, "saturation": 0.9 }
196
+ }
197
+ }
198
+ ```
199
+
200
+ - **Ambient Light.** `render.lighting.ambient` is where no light reaches: `color` (`#rrggbb`, default `#ffffff`) times `intensity` (0..1, default 1). `game.lighting.ambient` reads it and sets it at runtime, field by field (`game.lighting.ambient = { intensity: 0.2 }`), so a day/night cycle is a `game.time.tween` whose `onUpdate` sets it. Setting it lights a scene that declared none. It dies with its scene: a scene load or swap takes the next scene's value.
201
+ - **Light.** A component: `radius` (logical units), `color`, `intensity` (≥ 0), `bands` (0 smooth, up to 16 equal steps), `softness` (0 hard shadow edge, up to 1), `castShadows` (default true) and `offsetX`/`offsetY` from its entity. Its falloff is `1 − smoothstep(d / radius)`, measured in logical space, so on an isometric screen it is a 2:1 ellipse. Lights add to the Ambient Light and never darken; the sum clamps at 1. A scene with a live Light is lit even without `render.lighting`, but under full Ambient Light (`#ffffff`, 1) a light changes no pixel, so such a frame skips the light-map altogether. `game.lighting.lights` lists them.
202
+ - **Occlusion.** Only Tilemap `solidTiles` stop light: a point whose line to the light crosses a solid tile other than its own is in shadow, while the solid tile itself keeps the light that reaches it (its lit face). Every Tilemap of the scene contributes; the grid is rebuilt only when a Tilemap spawns, is destroyed or changes its tiles. Props with a `Solid` and moving entities cast no shadow.
203
+ - **Emissive.** `Sprite`, `AnimatedSprite` and `ParticleEmitter` take `emissive: true`: drawn after the light-map at full brightness (a flame, sparks), still in their own order and Sprite Batch runs. Every drawable discards its fully transparent texels, so an Emissive drawable behind a lit sprite shows through that sprite's transparent texels and stays hidden behind its opaque ones. Engine-wise they sit on `EMISSIVE_LAYER`; `setEmissive(object, true)` puts any three.js object there.
204
+ - **How a lit frame is drawn.** The scene without its Emissive drawables goes straight to the canvas as always; then a light-map — an 8-bit target the size of the fixed `resolution` (nearest upscale), or of the drawing buffer without one — cleared to the Ambient Light and lit by one quad per Light is multiplied over it; then the Emissive drawables. The multiply works on the canvas's sRGB values, so Ambient Light 1 with no light leaves the frame identical.
205
+ - **Post Effects.** `render.post.vignette` (`intensity` and `radius`, 0..1) darkens towards the corners; `render.post.colorGrade` (`tint`, `contrast` and `saturation` 0..2, 1 = unchanged) grades the frame. With either on, the frame (lighting and Emissive drawables included) renders into an 8-bit target at the light-map's size, drawn exactly as onto the canvas (sRGB-encoded, blending on sRGB values, so no sprite edge changes), and one full-screen pass grades it and writes it to the canvas. `game.post.vignette` and `game.post.colorGrade` read and set them field by field over the current values, like the ambient (null turns one off); they die with their scene. Camera Effects stay above and are never altered.
206
+ - **Validation and snapshots.** `validate_project` reports an out-of-range render field as `invalid-scene-render` and a Light param outside its range as `invalid-light-param`, naming it; at runtime out-of-range values are clamped. Every Runtime Snapshot carries `lighting: { ambient, lights: [{ entity, id, x, y, radius, color, intensity, bands, softness, castShadows }] }` in logical coordinates and `post: { vignette, colorGrade }` (null when off).
207
+
208
+ The CPU reference of a light-map texel — `lightFalloff`, `lightVisibility`, `lightMapValue` — is exported for tools and tests; the shader follows it.
209
+
186
210
  ## Component lifecycle
187
211
 
188
212
  Waica keeps the lifecycle boundaries distinct:
@@ -40,6 +40,9 @@ export declare class AnimatedSprite extends Component implements YSortParticipan
40
40
  max: number;
41
41
  step: number;
42
42
  };
43
+ emissive: {
44
+ label: string;
45
+ };
43
46
  };
44
47
  static transient: string[];
45
48
  /** Spritesheet URL. */
@@ -85,6 +88,9 @@ export declare class AnimatedSprite extends Component implements YSortParticipan
85
88
  flipX: boolean;
86
89
  /** Mirrors the quad horizontally — directional clips reuse east art for west. */
87
90
  setFlipX(value: boolean): void;
91
+ private _emissive;
92
+ get emissive(): boolean;
93
+ set emissive(value: boolean);
88
94
  clips: Record<string, ClipDef>;
89
95
  initialClip?: string;
90
96
  /** Clip currently playing. */
@@ -5,6 +5,7 @@ import { locateFrame, sheetCell } from '../animation/sheet.js';
5
5
  import { spritePlacement } from '../sprite-placement.js';
6
6
  import { reportRejection } from '../report-rejection.js';
7
7
  import { spriteBatchesOf, spriteInstanceOf } from '../sprite-batches.js';
8
+ import { setEmissive, TRANSPARENT_TEXEL_ALPHA } from '../render-layers.js';
8
9
  /** Where a frame on a failed sheet draws: the flat (white) untextured quad. */
9
10
  const UNTEXTURED = { texture: null, pixelArt: false, shape: 'rectangle' };
10
11
  const clampAnchor = (value) => Math.min(1, Math.max(0, value));
@@ -27,6 +28,7 @@ export class AnimatedSprite extends Component {
27
28
  anchorX: { label: 'x anchor', min: 0, max: 1, step: 0.25 },
28
29
  anchorY: { label: 'y anchor', min: 0, max: 1, step: 0.25 },
29
30
  layer: { label: 'layer', min: -5, max: 5, step: 1 },
31
+ emissive: { label: 'emissive' },
30
32
  };
31
33
  static transient = [
32
34
  'current',
@@ -133,6 +135,17 @@ export class AnimatedSprite extends Component {
133
135
  this.flipX = value;
134
136
  this.syncQuad();
135
137
  }
138
+ // Emissive (issue #78 CA-10): never darkened by the light-map, drawn after
139
+ // it at full brightness. On its own render layer.
140
+ _emissive = false;
141
+ get emissive() {
142
+ return this._emissive;
143
+ }
144
+ set emissive(value) {
145
+ this._emissive = value === true;
146
+ if (this.mesh)
147
+ setEmissive(this.mesh, this._emissive);
148
+ }
136
149
  clips = {};
137
150
  initialClip;
138
151
  /** Clip currently playing. */
@@ -182,10 +195,11 @@ export class AnimatedSprite extends Component {
182
195
  this.mesh = instance.anchor;
183
196
  }
184
197
  else {
185
- const material = new THREE.MeshBasicMaterial({ map: this.texs[0], transparent: true });
198
+ const material = new THREE.MeshBasicMaterial({ map: this.texs[0], transparent: true, alphaTest: TRANSPARENT_TEXEL_ALPHA });
186
199
  this.mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
187
200
  }
188
201
  this.mesh.position.z = this.layer * 0.01;
202
+ setEmissive(this.mesh, this._emissive);
189
203
  this.syncQuad();
190
204
  this.entity.node.add(this.mesh);
191
205
  if (this.initialClip)
@@ -0,0 +1,68 @@
1
+ import { Component } from '../component.js';
2
+ import type { LightField } from '../light-field.js';
3
+ /**
4
+ * A source of 2D light owned by its entity (issue #78, `CONTEXT.md` Light):
5
+ * a radius in logical world units — an ellipse on an isometric screen — a
6
+ * color, an intensity, a falloff that is smooth or split into `bands`, and a
7
+ * shadow edge that is hard or `soft`. Solid tiles stop it unless
8
+ * `castShadows` is off. Lights brighten what the Ambient Light leaves dark;
9
+ * they never darken. A live Light makes its scene draw lit.
10
+ */
11
+ export declare class Light extends Component {
12
+ static componentName: string;
13
+ static params: {
14
+ radius: {
15
+ label: string;
16
+ min: number;
17
+ step: number;
18
+ };
19
+ color: {
20
+ label: string;
21
+ kind: "color";
22
+ };
23
+ intensity: {
24
+ label: string;
25
+ min: number;
26
+ step: number;
27
+ };
28
+ bands: {
29
+ label: string;
30
+ min: number;
31
+ max: number;
32
+ step: number;
33
+ };
34
+ softness: {
35
+ label: string;
36
+ min: number;
37
+ max: number;
38
+ step: number;
39
+ };
40
+ castShadows: {
41
+ label: string;
42
+ };
43
+ offsetX: {
44
+ label: string;
45
+ };
46
+ offsetY: {
47
+ label: string;
48
+ };
49
+ };
50
+ /** Logical world units from its centre to where it fades out. */
51
+ radius: number;
52
+ /** `0xrrggbb`, multiplied over the art it reaches. */
53
+ color: number;
54
+ intensity: number;
55
+ /** 0 = smooth falloff; N = N equal steps (at most 16). */
56
+ bands: number;
57
+ /** 0 = hard shadow edge; up to 1 = the widest penumbra. */
58
+ softness: number;
59
+ /** Off: solid tiles do not stop this light. */
60
+ castShadows: boolean;
61
+ /** Logical offset from the entity's position, like a Solid's. */
62
+ offsetX: number;
63
+ offsetY: number;
64
+ onReady(): void;
65
+ onDestroy(): void;
66
+ /** This light as the light-map reads it: logical position, clamped params, color channels in 0..1. */
67
+ field(): LightField;
68
+ }
@@ -0,0 +1,59 @@
1
+ import { Component } from '../component.js';
2
+ import { addLight, removeLight } from '../scene-lighting.js';
3
+ const MAX_BANDS = 16;
4
+ const finiteOr = (value, fallback) => (Number.isFinite(value) ? value : fallback);
5
+ /**
6
+ * A source of 2D light owned by its entity (issue #78, `CONTEXT.md` Light):
7
+ * a radius in logical world units — an ellipse on an isometric screen — a
8
+ * color, an intensity, a falloff that is smooth or split into `bands`, and a
9
+ * shadow edge that is hard or `soft`. Solid tiles stop it unless
10
+ * `castShadows` is off. Lights brighten what the Ambient Light leaves dark;
11
+ * they never darken. A live Light makes its scene draw lit.
12
+ */
13
+ export class Light extends Component {
14
+ static componentName = 'Light';
15
+ static params = {
16
+ radius: { label: 'Radius', min: 0, step: 0.25 },
17
+ color: { label: 'Color', kind: 'color' },
18
+ intensity: { label: 'Intensity', min: 0, step: 0.1 },
19
+ bands: { label: 'Bands (0 = smooth)', min: 0, max: MAX_BANDS, step: 1 },
20
+ softness: { label: 'Shadow softness', min: 0, max: 1, step: 0.05 },
21
+ castShadows: { label: 'Cast shadows' },
22
+ offsetX: { label: 'x offset' },
23
+ offsetY: { label: 'y offset' },
24
+ };
25
+ /** Logical world units from its centre to where it fades out. */
26
+ radius = 4;
27
+ /** `0xrrggbb`, multiplied over the art it reaches. */
28
+ color = 0xffffff;
29
+ intensity = 1;
30
+ /** 0 = smooth falloff; N = N equal steps (at most 16). */
31
+ bands = 0;
32
+ /** 0 = hard shadow edge; up to 1 = the widest penumbra. */
33
+ softness = 0;
34
+ /** Off: solid tiles do not stop this light. */
35
+ castShadows = true;
36
+ /** Logical offset from the entity's position, like a Solid's. */
37
+ offsetX = 0;
38
+ offsetY = 0;
39
+ onReady() {
40
+ addLight(this.game.lighting, this);
41
+ }
42
+ onDestroy() {
43
+ removeLight(this.game.lighting, this);
44
+ }
45
+ /** This light as the light-map reads it: logical position, clamped params, color channels in 0..1. */
46
+ field() {
47
+ const color = Number.isFinite(this.color) ? Math.trunc(this.color) & 0xffffff : 0xffffff;
48
+ return {
49
+ x: this.entity.position.x + finiteOr(this.offsetX, 0),
50
+ y: this.entity.position.y + finiteOr(this.offsetY, 0),
51
+ radius: Math.max(0, finiteOr(this.radius, 0)),
52
+ color: [((color >> 16) & 0xff) / 255, ((color >> 8) & 0xff) / 255, (color & 0xff) / 255],
53
+ intensity: Math.max(0, finiteOr(this.intensity, 0)),
54
+ bands: Math.min(MAX_BANDS, Math.max(0, Math.round(finiteOr(this.bands, 0)))),
55
+ softness: Math.min(1, Math.max(0, finiteOr(this.softness, 0))),
56
+ castShadows: this.castShadows !== false,
57
+ };
58
+ }
59
+ }
@@ -107,6 +107,9 @@ export declare class ParticleEmitter extends Component implements YSortBatchPart
107
107
  label: string;
108
108
  step: number;
109
109
  };
110
+ emissive: {
111
+ label: string;
112
+ };
110
113
  };
111
114
  rate: number;
112
115
  emitting: boolean;
@@ -144,6 +147,9 @@ export declare class ParticleEmitter extends Component implements YSortBatchPart
144
147
  private _layer;
145
148
  get layer(): number;
146
149
  set layer(value: number);
150
+ private _emissive;
151
+ get emissive(): boolean;
152
+ set emissive(value: boolean);
147
153
  private _batch?;
148
154
  private _unsubscribeRenderSync?;
149
155
  private _emissionRemainder;
@@ -1,6 +1,7 @@
1
1
  import { Component } from '../component.js';
2
2
  import { ParticleBatch, } from '../particle-batch.js';
3
3
  import { sceneDrainsOf } from '../scene-drains.js';
4
+ import { setEmissive } from '../render-layers.js';
4
5
  /** A fixed-capacity source of short-lived, batched 2D particles. */
5
6
  export class ParticleEmitter extends Component {
6
7
  static componentName = 'ParticleEmitter';
@@ -30,6 +31,7 @@ export class ParticleEmitter extends Component {
30
31
  pixelArt: { label: 'Pixel art' },
31
32
  blend: { label: 'Blend', options: ['normal', 'additive'] },
32
33
  layer: { label: 'Layer', step: 1 },
34
+ emissive: { label: 'Emissive' },
33
35
  };
34
36
  rate = 0;
35
37
  emitting = true;
@@ -116,6 +118,16 @@ export class ParticleEmitter extends Component {
116
118
  this._batch?.setLayer(next);
117
119
  this._batch?.sync(this.renderContext());
118
120
  }
121
+ // Emissive (issue #78 CA-10): never darkened by the light-map.
122
+ _emissive = false;
123
+ get emissive() {
124
+ return this._emissive;
125
+ }
126
+ set emissive(value) {
127
+ this._emissive = value === true;
128
+ if (this._batch)
129
+ setEmissive(this._batch.mesh, this._emissive);
130
+ }
119
131
  _batch;
120
132
  _unsubscribeRenderSync;
121
133
  _emissionRemainder = 0;
@@ -133,6 +145,7 @@ export class ParticleEmitter extends Component {
133
145
  assets: this.game.assets,
134
146
  });
135
147
  this._batch.setTexture(this.texture, this.pixelArt);
148
+ setEmissive(this._batch.mesh, this._emissive);
136
149
  this.game.scene.add(this._batch.mesh);
137
150
  this._batch.sync(this.renderContext());
138
151
  this._unsubscribeRenderSync = this.game.onUpdate(() => this._batch?.sync(this.renderContext()));
@@ -32,6 +32,9 @@ export declare class Sprite extends Component implements YSortParticipant {
32
32
  max: number;
33
33
  step: number;
34
34
  };
35
+ emissive: {
36
+ label: string;
37
+ };
35
38
  };
36
39
  static transient: string[];
37
40
  private _width;
@@ -55,6 +58,9 @@ export declare class Sprite extends Component implements YSortParticipant {
55
58
  set anchorX(value: number);
56
59
  get anchorY(): number;
57
60
  set anchorY(value: number);
61
+ private _emissive;
62
+ get emissive(): boolean;
63
+ set emissive(value: boolean);
58
64
  /** Optional texture URL; with pixelArt on it filters in nearest. */
59
65
  texture?: string;
60
66
  pixelArt: boolean;
@@ -3,6 +3,7 @@ import { Component } from '../component.js';
3
3
  import { spritePlacement } from '../sprite-placement.js';
4
4
  import { reportRejection } from '../report-rejection.js';
5
5
  import { spriteBatchesOf, spriteInstanceOf } from '../sprite-batches.js';
6
+ import { setEmissive, TRANSPARENT_TEXEL_ALPHA } from '../render-layers.js';
6
7
  const clampAnchor = (value) => Math.min(1, Math.max(0, value));
7
8
  /**
8
9
  * Textured or flat-color quad. In the unified pipeline, a 2D sprite is a
@@ -16,6 +17,7 @@ export class Sprite extends Component {
16
17
  anchorX: { label: 'x anchor', min: 0, max: 1, step: 0.25 },
17
18
  anchorY: { label: 'y anchor', min: 0, max: 1, step: 0.25 },
18
19
  layer: { label: 'layer', min: -5, max: 5, step: 1 },
20
+ emissive: { label: 'emissive' },
19
21
  };
20
22
  static transient = ['mesh'];
21
23
  // Size, color and offset are reactive so inspector edits update the live quad.
@@ -80,6 +82,17 @@ export class Sprite extends Component {
80
82
  this._anchorY = clampAnchor(value);
81
83
  this.syncQuad();
82
84
  }
85
+ // Emissive (issue #78 CA-10): never darkened by the light-map, drawn after
86
+ // it at full brightness. On its own render layer.
87
+ _emissive = false;
88
+ get emissive() {
89
+ return this._emissive;
90
+ }
91
+ set emissive(value) {
92
+ this._emissive = value === true;
93
+ if (this.mesh)
94
+ setEmissive(this.mesh, this._emissive);
95
+ }
83
96
  /** Optional texture URL; with pixelArt on it filters in nearest. */
84
97
  texture;
85
98
  pixelArt = false;
@@ -127,11 +140,12 @@ export class Sprite extends Component {
127
140
  if (!this.mesh)
128
141
  return;
129
142
  this.mesh.position.z = this.layer * 0.01;
143
+ setEmissive(this.mesh, this._emissive);
130
144
  this.syncQuad();
131
145
  this.entity.node.add(this.mesh);
132
146
  }
133
147
  buildMesh() {
134
- const material = new THREE.MeshBasicMaterial({ color: this.color, transparent: true });
148
+ const material = new THREE.MeshBasicMaterial({ color: this.color, transparent: true, alphaTest: TRANSPARENT_TEXEL_ALPHA });
135
149
  if (this.texture) {
136
150
  // Its own clone of the cached base (game.assets, ADR 0019): filters
137
151
  // are per clone, colour space comes with the base, and the image lands
@@ -6,6 +6,8 @@ import { SOLID_SOURCE_SYMBOL } from '../scene-solids.js';
6
6
  import { cellAt as gridCellAt, cellBounds as gridCellBounds, cellIndex as gridCellIndex, } from '../tilemap-grid.js';
7
7
  import { Solid } from './solid.js';
8
8
  import { reportRejection } from '../report-rejection.js';
9
+ import { markOccludersChanged } from '../scene-lighting.js';
10
+ import { TRANSPARENT_TEXEL_ALPHA } from '../render-layers.js';
9
11
  /** One authorable cell map rendered as a single merged geometry. */
10
12
  export class Tilemap extends Component {
11
13
  static componentName = 'Tilemap';
@@ -175,6 +177,7 @@ export class Tilemap extends Component {
175
177
  this.loadedTexture?.dispose();
176
178
  this.loadedTexture = undefined;
177
179
  this.derivedSolids = [];
180
+ markOccludersChanged(this.game.lighting);
178
181
  }
179
182
  solids() {
180
183
  return this.derivedSolids;
@@ -255,6 +258,7 @@ export class Tilemap extends Component {
255
258
  return new THREE.MeshBasicMaterial({
256
259
  color: this.color,
257
260
  transparent: true,
261
+ alphaTest: TRANSPARENT_TEXEL_ALPHA,
258
262
  });
259
263
  }
260
264
  rebuildGeometry() {
@@ -349,5 +353,7 @@ export class Tilemap extends Component {
349
353
  next.push(solid);
350
354
  }
351
355
  this.derivedSolids = next;
356
+ // Occlusion is rebuilt from the solid tiles only when they change (inference 9).
357
+ markOccludersChanged(this.game.lighting);
352
358
  }
353
359
  }
@@ -0,0 +1,21 @@
1
+ import * as THREE from 'three/webgpu';
2
+ /** Whether `value` is the scene of a FrameQuad. */
3
+ export declare function isFrameQuad(value: unknown): boolean;
4
+ /**
5
+ * A full-viewport pass drawn as an ordinary scene with its own camera — not
6
+ * a THREE.QuadMesh: on the WebGPU backend a QuadMesh drawn to the canvas
7
+ * marks that render a full-screen pass, and the next render to the canvas in
8
+ * the same frame then loses everything drawn before it (seen in the issue
9
+ * #78 e2e). The lit frame draws its Emissive drawables after its multiply
10
+ * quad, so its quad must be ordinary.
11
+ */
12
+ export declare class FrameQuad {
13
+ readonly material: THREE.Material;
14
+ private readonly scene;
15
+ private readonly camera;
16
+ private readonly mesh;
17
+ constructor(material: THREE.Material);
18
+ /** Draws the quad into whatever is bound now, inside the current viewport. */
19
+ render(renderer: THREE.WebGPURenderer): void;
20
+ dispose(): void;
21
+ }
@@ -0,0 +1,50 @@
1
+ import * as THREE from 'three/webgpu';
2
+ /** The scenes FrameQuad draws, so a test can tell them from any other render. */
3
+ const quadScenes = new WeakSet();
4
+ /** Whether `value` is the scene of a FrameQuad. */
5
+ export function isFrameQuad(value) {
6
+ return typeof value === 'object' && value !== null && quadScenes.has(value);
7
+ }
8
+ /**
9
+ * Two triangles covering the viewport, with three's QuadMesh UV convention
10
+ * (v = 1 at the bottom), so a render target samples upright on both Render
11
+ * Backends — three flips a render target's v itself on WebGL2.
12
+ */
13
+ function quadGeometry() {
14
+ const geometry = new THREE.BufferGeometry();
15
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute([-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0], 3));
16
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 1, 1, 1, 1, 0, 0, 0], 2));
17
+ geometry.setIndex([0, 1, 2, 0, 2, 3]);
18
+ return geometry;
19
+ }
20
+ /**
21
+ * A full-viewport pass drawn as an ordinary scene with its own camera — not
22
+ * a THREE.QuadMesh: on the WebGPU backend a QuadMesh drawn to the canvas
23
+ * marks that render a full-screen pass, and the next render to the canvas in
24
+ * the same frame then loses everything drawn before it (seen in the issue
25
+ * #78 e2e). The lit frame draws its Emissive drawables after its multiply
26
+ * quad, so its quad must be ordinary.
27
+ */
28
+ export class FrameQuad {
29
+ material;
30
+ scene = new THREE.Scene();
31
+ camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
32
+ mesh;
33
+ constructor(material) {
34
+ this.material = material;
35
+ this.mesh = new THREE.Mesh(quadGeometry(), material);
36
+ this.mesh.position.z = -0.5;
37
+ this.mesh.frustumCulled = false;
38
+ this.scene.add(this.mesh);
39
+ this.scene.matrixWorldAutoUpdate = true;
40
+ quadScenes.add(this.scene);
41
+ }
42
+ /** Draws the quad into whatever is bound now, inside the current viewport. */
43
+ render(renderer) {
44
+ renderer.render(this.scene, this.camera);
45
+ }
46
+ dispose() {
47
+ this.mesh.geometry.dispose();
48
+ this.material.dispose();
49
+ }
50
+ }
package/dist/game.d.ts CHANGED
@@ -11,8 +11,10 @@ import { Emitter } from './events.js';
11
11
  import { GameTime } from './game-time.js';
12
12
  import { Input, type InputBindings } from './input.js';
13
13
  import { Pointer } from './pointer.js';
14
+ import { GamePost } from './post-effects.js';
14
15
  import { type RenderBackend } from './render-readiness.js';
15
16
  import { type SceneJson, type SceneRegistry, type SceneRenderJson } from './scene.js';
17
+ import { GameLighting } from './scene-lighting.js';
16
18
  import { type SpatialQuery } from './spatial-query.js';
17
19
  import { Stats, type StatValue } from './stats.js';
18
20
  import { GameUi } from './ui.js';
@@ -94,6 +96,10 @@ export declare class Game {
94
96
  readonly cameraEffects: CameraEffects;
95
97
  /** Simulated scheduling: `after`, `every`, `tween`, `now`. See ADR 0017. */
96
98
  readonly time: GameTime;
99
+ /** The live scene's Ambient Light and Lights (ADR 0026); it dies with its scene. */
100
+ readonly lighting: GameLighting;
101
+ /** The live scene's Post Effects (vignette, color grade); they die with their scene. */
102
+ readonly post: GamePost;
97
103
  /** Registry retained by loadScene for runtime prefab spawning. */
98
104
  registry: SceneRegistry | null;
99
105
  paramOverrides: ParamOverrides;
@@ -108,6 +114,8 @@ export declare class Game {
108
114
  private readonly resizeObserver;
109
115
  /** Scene-scoped Sprite Batches (ADR 0024); `render.batch: false` opts a scene out. */
110
116
  private readonly spriteBatches;
117
+ /** Off, lit or through the Post Effects: how each frame is drawn (ADR 0026). */
118
+ private readonly frame;
111
119
  private readonly updateFns;
112
120
  private readonly invalidUpdateCompositions;
113
121
  /**
package/dist/game.js CHANGED
@@ -12,14 +12,18 @@ import { consumeSimulationSteps, MAX_CHAINED_HOPS, SIMULATION_STEP, snapElapsedT
12
12
  import { advanceGameTime, GameTime } from './game-time.js';
13
13
  import { Input } from './input.js';
14
14
  import { Pointer } from './pointer.js';
15
+ import { GamePost } from './post-effects.js';
15
16
  import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
16
17
  import { RuntimeInspector } from './runtime-inspection.js';
18
+ import { FrameComposer } from './lit-frame.js';
19
+ import { EMISSIVE_LAYER } from './render-layers.js';
17
20
  import { projectIsometric, unprojectIsometric } from './projection.js';
18
21
  import { canvasBackground, drawStraightToCanvas, mapUvPerVertex } from './render-output.js';
19
22
  import { RenderReadiness } from './render-readiness.js';
20
23
  import { applyYSort } from './render-sort.js';
21
24
  import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
22
25
  import { sceneDrainsOf } from './scene-drains.js';
26
+ import { GameLighting } from './scene-lighting.js';
23
27
  import { createSpriteBatches } from './sprite-batches.js';
24
28
  import { createSpatialQuery } from './spatial-query.js';
25
29
  import { Stats } from './stats.js';
@@ -64,6 +68,10 @@ export class Game {
64
68
  cameraEffects;
65
69
  /** Simulated scheduling: `after`, `every`, `tween`, `now`. See ADR 0017. */
66
70
  time = new GameTime();
71
+ /** The live scene's Ambient Light and Lights (ADR 0026); it dies with its scene. */
72
+ lighting = new GameLighting();
73
+ /** The live scene's Post Effects (vignette, color grade); they die with their scene. */
74
+ post = new GamePost();
67
75
  /** Registry retained by loadScene for runtime prefab spawning. */
68
76
  registry = null;
69
77
  paramOverrides = {};
@@ -78,6 +86,8 @@ export class Game {
78
86
  resizeObserver;
79
87
  /** Scene-scoped Sprite Batches (ADR 0024); `render.batch: false` opts a scene out. */
80
88
  spriteBatches;
89
+ /** Off, lit or through the Post Effects: how each frame is drawn (ADR 0026). */
90
+ frame;
81
91
  updateFns = new Set();
82
92
  invalidUpdateCompositions = new WeakMap();
83
93
  /**
@@ -162,6 +172,10 @@ export class Game {
162
172
  this.scene.background = canvasBackground(background);
163
173
  this.camera = new THREE.OrthographicCamera();
164
174
  this.camera.position.z = 10;
175
+ // Emissive drawables sit on their own layer; an unlit frame draws it in place.
176
+ this.camera.layers.enable(EMISSIVE_LAYER);
177
+ const { renderer, spriteBatches, resolution } = this;
178
+ this.frame = new FrameComposer({ game: this, renderer, spriteBatches, resolution });
165
179
  this.pointer = new Pointer(canvas, {
166
180
  camera: this.camera,
167
181
  resolution: this.resolution,
@@ -228,6 +242,8 @@ export class Game {
228
242
  // destroyed below, so an owner's own destroy() cancellation is a no-op.
229
243
  this.time.cancelSceneScoped();
230
244
  this.cameraEffects.unloadScene();
245
+ this.lighting.unloadScene();
246
+ this.post.unloadScene();
231
247
  // An explicit unload means "no scene": a swap queued earlier this frame
232
248
  // would otherwise flush next frame and resurrect one.
233
249
  this.pendingSceneLoad = null;
@@ -334,6 +350,8 @@ export class Game {
334
350
  setSceneRender(json) {
335
351
  this.renderSort = json?.sort === 'y' ? 'y' : null;
336
352
  this.spriteBatches.enabled = json?.batch !== false;
353
+ this.lighting.loadScene(json);
354
+ this.post.loadScene(json);
337
355
  const projection = json?.projection === 'isometric' ? 'isometric' : null;
338
356
  if (projection === this.sceneProjection)
339
357
  return;
@@ -449,6 +467,7 @@ export class Game {
449
467
  // After the entities: their clones go with the cascade above, the
450
468
  // cached bases go here, exactly once (ADR 0019).
451
469
  this.assets.dispose();
470
+ this.frame.dispose();
452
471
  this.readiness.disposeRenderer();
453
472
  }
454
473
  /**
@@ -618,14 +637,7 @@ export class Game {
618
637
  // Before ready() the frame simulated, but three cannot draw yet.
619
638
  if (!this.readiness.isReady)
620
639
  return;
621
- if (this.resolution) {
622
- // Letterbox bars: clear the whole canvas, then render inside the scissor.
623
- this.renderer.setScissorTest(false);
624
- this.renderer.setClearColor(0x000000, 1);
625
- this.renderer.clear(true, false, false);
626
- this.renderer.setScissorTest(true);
627
- }
628
- this.spriteBatches.drawFrame(this.scene, this.camera, () => this.renderer.render(this.scene, this.camera));
640
+ this.frame.draw();
629
641
  }
630
642
  finally {
631
643
  this.camera.position.x = x;
package/dist/index.d.ts CHANGED
@@ -40,6 +40,7 @@ export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_B
40
40
  export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeBridgeFailure, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
41
41
  export type { RenderBackend } from './render-readiness.js';
42
42
  export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
43
+ export type { RuntimeSnapshotLight, RuntimeSnapshotLighting, RuntimeSnapshotPost, } from './runtime-lighting-snapshot.js';
43
44
  export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotCamera, RuntimeSnapshotFilters, RuntimeSnapshotTime, RuntimeSnapshotUi, RuntimeTransformSnapshot, } from './runtime-inspection.js';
44
45
  export type { ArchetypeArt, ArchetypeManifest, BrowserArchetypeManifest, EntityTemplate, } from './archetype.js';
45
46
  export { Stats } from './stats.js';
@@ -47,6 +48,15 @@ export type { StatValue } from './stats.js';
47
48
  export { GameUi } from './ui.js';
48
49
  export type { AnchoredPieceHandle, AttachOptions } from './anchored-pieces.js';
49
50
  export { Sprite } from './components/sprite.js';
51
+ export { Light } from './components/light.js';
52
+ export { GameLighting } from './scene-lighting.js';
53
+ export type { AmbientLightInput } from './scene-lighting.js';
54
+ export { GamePost } from './post-effects.js';
55
+ export { EMISSIVE_LAYER, setEmissive } from './render-layers.js';
56
+ export { lightFalloff, lightMapValue, lightVisibility } from './light-field.js';
57
+ export type { LightField, OccluderGrid, Rgb } from './light-field.js';
58
+ export { sceneRenderIssues } from './scene-render-options.js';
59
+ export type { AmbientLight, ColorGradeEffect, PostEffectsState, SceneRenderIssue, VignetteEffect, } from './scene-render-options.js';
50
60
  export { ParticleEmitter } from './components/particle-emitter.js';
51
61
  export type { ParticleBlend, ParticleDestroyMode, ParticleOverflow, ParticleSpace, ParticleVector, } from './components/particle-emitter.js';
52
62
  export { Solid } from './components/solid.js';
@@ -66,7 +76,7 @@ export type { CollisionBody, CollisionBounds, CollisionPoint, CollisionShape, }
66
76
  export type { EntityWith, NearestQueryContext, NearestSpatialQueryFilter, RayHit, SpatialQuery, SpatialQueryFilter, } from './spatial-query.js';
67
77
  export { Emitter } from './events.js';
68
78
  export { loadScene, spawnFromJson, resolveEntityComponents, resolveProps } from './scene.js';
69
- export type { SceneJson, SceneEntityJson, SceneComponentJson, SceneRegistry, SceneRenderJson, PrefabJson, } from './scene.js';
79
+ export type { SceneJson, SceneEntityJson, SceneComponentJson, SceneRegistry, SceneRenderJson, SceneLightingJson, ScenePostJson, PrefabJson, } from './scene.js';
70
80
  export { ClipPlayer } from './animation/clip-player.js';
71
81
  export type { ClipDef } from './animation/clip-player.js';
72
82
  export { sheetCell, sheetFrameCount, locateFrame } from './animation/sheet.js';
package/dist/index.js CHANGED
@@ -22,6 +22,12 @@ export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
22
22
  export { Stats } from './stats.js';
23
23
  export { GameUi } from './ui.js';
24
24
  export { Sprite } from './components/sprite.js';
25
+ export { Light } from './components/light.js';
26
+ export { GameLighting } from './scene-lighting.js';
27
+ export { GamePost } from './post-effects.js';
28
+ export { EMISSIVE_LAYER, setEmissive } from './render-layers.js';
29
+ export { lightFalloff, lightMapValue, lightVisibility } from './light-field.js';
30
+ export { sceneRenderIssues } from './scene-render-options.js';
25
31
  export { ParticleEmitter } from './components/particle-emitter.js';
26
32
  export { Solid } from './components/solid.js';
27
33
  export { Hitbox } from './components/hitbox.js';