@waica/engine 0.23.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 (68) hide show
  1. package/README.md +43 -0
  2. package/dist/assets/asset-loader.d.ts +1 -1
  3. package/dist/assets/asset-loader.js +1 -1
  4. package/dist/assets/texture-backend.d.ts +1 -1
  5. package/dist/assets/texture-backend.js +1 -1
  6. package/dist/camera.js +1 -1
  7. package/dist/components/animated-sprite.d.ts +6 -0
  8. package/dist/components/animated-sprite.js +16 -2
  9. package/dist/components/light.d.ts +68 -0
  10. package/dist/components/light.js +59 -0
  11. package/dist/components/particle-emitter.d.ts +6 -0
  12. package/dist/components/particle-emitter.js +13 -0
  13. package/dist/components/sprite.d.ts +6 -0
  14. package/dist/components/sprite.js +16 -2
  15. package/dist/components/tilemap.js +9 -1
  16. package/dist/entity.d.ts +1 -1
  17. package/dist/entity.js +1 -1
  18. package/dist/frame-quad.d.ts +21 -0
  19. package/dist/frame-quad.js +50 -0
  20. package/dist/game.d.ts +28 -3
  21. package/dist/game.js +79 -52
  22. package/dist/index.d.ts +14 -3
  23. package/dist/index.js +9 -1
  24. package/dist/light-field.d.ts +78 -0
  25. package/dist/light-field.js +145 -0
  26. package/dist/light-map-material.d.ts +40 -0
  27. package/dist/light-map-material.js +203 -0
  28. package/dist/light-map.d.ts +46 -0
  29. package/dist/light-map.js +132 -0
  30. package/dist/lit-frame.d.ts +48 -0
  31. package/dist/lit-frame.js +167 -0
  32. package/dist/occluder-grid.d.ts +19 -0
  33. package/dist/occluder-grid.js +50 -0
  34. package/dist/particle-batch.d.ts +1 -1
  35. package/dist/particle-batch.js +7 -3
  36. package/dist/particle-sort-bounds.d.ts +1 -1
  37. package/dist/particle-sort-bounds.js +1 -1
  38. package/dist/pointer.js +1 -1
  39. package/dist/post-effects.d.ts +33 -0
  40. package/dist/post-effects.js +48 -0
  41. package/dist/post-pass.d.ts +23 -0
  42. package/dist/post-pass.js +93 -0
  43. package/dist/render-layers.d.ts +18 -0
  44. package/dist/render-layers.js +19 -0
  45. package/dist/render-output.d.ts +34 -0
  46. package/dist/render-output.js +60 -0
  47. package/dist/render-readiness.d.ts +37 -0
  48. package/dist/render-readiness.js +61 -0
  49. package/dist/render-sort.d.ts +14 -0
  50. package/dist/render-sort.js +37 -0
  51. package/dist/runtime-bridge.d.ts +18 -2
  52. package/dist/runtime-bridge.js +7 -1
  53. package/dist/runtime-inspection.d.ts +5 -0
  54. package/dist/runtime-inspection.js +5 -1
  55. package/dist/runtime-lighting-snapshot.d.ts +41 -0
  56. package/dist/runtime-lighting-snapshot.js +46 -0
  57. package/dist/scene-lighting.d.ts +52 -0
  58. package/dist/scene-lighting.js +107 -0
  59. package/dist/scene-render-options.d.ts +51 -0
  60. package/dist/scene-render-options.js +126 -0
  61. package/dist/scene.d.ts +29 -0
  62. package/dist/sprite-batch.d.ts +3 -3
  63. package/dist/sprite-batch.js +45 -29
  64. package/dist/sprite-batches.d.ts +2 -2
  65. package/dist/sprite-batches.js +1 -1
  66. package/dist/sprite-draw-collector.d.ts +3 -3
  67. package/dist/sprite-draw-collector.js +3 -3
  68. package/package.json +1 -1
package/README.md CHANGED
@@ -6,6 +6,25 @@ Waica's public engine core: entities and components, the game loop, scene and pr
6
6
  import { Component, Game, loadScene } from '@waica/engine'
7
7
  ```
8
8
 
9
+ ## Migrating to 0.24.0: WebGPURenderer and `game.ready()`
10
+
11
+ A `Game` draws through three's `WebGPURenderer` (ADR 0025): WebGPU when the browser offers it, otherwise the renderer's own WebGL2 fallback. The browser decides — there is no option to force either one — and `game.backend` reports which one it got (`'webgpu'` or `'webgl2'`; `null` until the renderer is ready).
12
+
13
+ - **Await `game.ready()` before the first frame.** `new Game(...)` stays synchronous, but the renderer initializes asynchronously. Frames before it finishes still simulate (steps, `onUpdate`) but draw nothing, so a host waits for it before `game.start()`:
14
+
15
+ ```ts
16
+ const game = new Game({ canvas })
17
+ loadScene(game, scene, registry)
18
+ await game.assets.ready()
19
+ await game.ready()
20
+ game.start()
21
+ ```
22
+
23
+ Every call returns the same promise. It rejects with an error naming both `webgpu` and `webgl2` when neither initializes; nothing is ever drawn then. A `Game` disposed while `ready()` is pending never starts, and its renderer is released once initialization settles.
24
+ - **`THREE` is the `three/webgpu` build.** `import { THREE } from '@waica/engine'` now re-exports `three/webgpu`, the same copy of three the engine draws with: `THREE.WebGPURenderer` exists and `THREE.WebGLRenderer` does not. Custom GLSL does not render under `WebGPURenderer` — `ShaderMaterial`, `RawShaderMaterial` and `material.onBeforeCompile` are not supported there — so write custom shading as node materials with TSL (`three/tsl`). Built-in materials such as `MeshBasicMaterial` keep working unchanged.
25
+ - **Tests under happy-dom.** happy-dom has no GPU: a project test that builds a `Game` replaces `WebGPURenderer` from `three/webgpu` with a fake (it previously replaced `WebGLRenderer` from `three`), whose `init()` resolves.
26
+ - **Run Sessions.** A Run Session waits for the Render Backend before it reports ready, steps or screenshots, and its snapshots carry `backend`. A renderer that cannot initialize ends `start_project` with a runtime error that names the failure.
27
+
9
28
  ## Hitbox Collision Layers and Masks
10
29
 
11
30
  Every `Hitbox` belongs to one named Collision Layer and declares the other layers in which it is interested through a Collision Mask:
@@ -164,6 +183,30 @@ Under batching, the object a sprite adds to its entity is a hidden placement anc
164
183
 
165
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.
166
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
+
167
210
  ## Component lifecycle
168
211
 
169
212
  Waica keeps the lifecycle boundaries distinct:
@@ -1,4 +1,4 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  import { type TextureBackend } from './texture-backend.js';
3
3
  /** `game.assets.status`: a fresh snapshot of the cache's counters, cumulative for the Game. */
4
4
  export interface AssetStatus {
@@ -1,4 +1,4 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  import { ThreeTextureBackend } from './texture-backend.js';
3
3
  import { reportRejection } from '../report-rejection.js';
4
4
  /**
@@ -1,4 +1,4 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  /**
3
3
  * The seam between `game.assets` and the browser (ADR 0013's move, applied
4
4
  * to textures — ADR 0019). `load` resolves once the image behind `url` is
@@ -1,4 +1,4 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  /** The real implementation: one `THREE.TextureLoader` per Game, wrapped in a promise. */
3
3
  export class ThreeTextureBackend {
4
4
  loader = new THREE.TextureLoader();
package/dist/camera.js CHANGED
@@ -1,4 +1,4 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  export const CAMERA_DEFAULTS = {
3
3
  position: [0, 0],
4
4
  zoom: 12,
@@ -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. */
@@ -1,10 +1,11 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  import { Component } from '../component.js';
3
3
  import { ClipPlayer } from '../animation/clip-player.js';
4
4
  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;
@@ -1,8 +1,9 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  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
@@ -1,4 +1,4 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  import { sheetCell } from '../animation/sheet.js';
3
3
  import { Component } from '../component.js';
4
4
  import { projectIsometric } from '../projection.js';
@@ -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() {
@@ -283,6 +287,8 @@ export class Tilemap extends Component {
283
287
  geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(buffers.indices), 1));
284
288
  mesh.geometry.dispose();
285
289
  mesh.geometry = geometry;
290
+ // WebGPURenderer issues a zero-count draw (WebGLRenderer skipped it): no tile, no draw.
291
+ mesh.visible = buffers.indices.length > 0;
286
292
  }
287
293
  /** The loaded sheet image's size, or one pixel per sheet cell until it arrives. */
288
294
  sheetSize() {
@@ -347,5 +353,7 @@ export class Tilemap extends Component {
347
353
  next.push(solid);
348
354
  }
349
355
  this.derivedSolids = next;
356
+ // Occlusion is rebuilt from the solid tiles only when they change (inference 9).
357
+ markOccludersChanged(this.game.lighting);
350
358
  }
351
359
  }
package/dist/entity.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  import type { Component, ComponentClass } from './component.js';
3
3
  import type { Game } from './game.js';
4
4
  /**
package/dist/entity.js CHANGED
@@ -1,4 +1,4 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  /**
3
3
  * A live scene node: a transform (three Group) + components.
4
4
  * Created with `game.spawn(name)`.
@@ -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
@@ -1,4 +1,4 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  import { AssetLoader } from './assets/asset-loader.js';
3
3
  import type { TextureBackend } from './assets/texture-backend.js';
4
4
  import type { AudioBackend } from './audio/backend.js';
@@ -11,7 +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';
15
+ import { type RenderBackend } from './render-readiness.js';
14
16
  import { type SceneJson, type SceneRegistry, type SceneRenderJson } from './scene.js';
17
+ import { GameLighting } from './scene-lighting.js';
15
18
  import { type SpatialQuery } from './spatial-query.js';
16
19
  import { Stats, type StatValue } from './stats.js';
17
20
  import { GameUi } from './ui.js';
@@ -93,6 +96,10 @@ export declare class Game {
93
96
  readonly cameraEffects: CameraEffects;
94
97
  /** Simulated scheduling: `after`, `every`, `tween`, `now`. See ADR 0017. */
95
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;
96
103
  /** Registry retained by loadScene for runtime prefab spawning. */
97
104
  registry: SceneRegistry | null;
98
105
  paramOverrides: ParamOverrides;
@@ -101,10 +108,14 @@ export declare class Game {
101
108
  * or collisions — the editor's edit mode.
102
109
  */
103
110
  simulate: boolean;
111
+ /** WebGPU when the browser offers it, else three's WebGL2 fallback (ADR 0025). */
104
112
  private readonly renderer;
113
+ private readonly readiness;
105
114
  private readonly resizeObserver;
106
115
  /** Scene-scoped Sprite Batches (ADR 0024); `render.batch: false` opts a scene out. */
107
116
  private readonly spriteBatches;
117
+ /** Off, lit or through the Post Effects: how each frame is drawn (ADR 0026). */
118
+ private readonly frame;
108
119
  private readonly updateFns;
109
120
  private readonly invalidUpdateCompositions;
110
121
  /**
@@ -135,9 +146,20 @@ export declare class Game {
135
146
  private liveSceneName;
136
147
  /** True for the whole extent of a runFrame() call, incl. its tail. */
137
148
  private insideFrame;
149
+ /** Set by dispose(): a disposed Game never starts again nor reports its renderer (review #3). */
150
+ private disposed;
138
151
  /** A loadSceneByName() enqueued while insideFrame; applied at the next runFrame's start. */
139
152
  private pendingSceneLoad;
140
153
  constructor(options: GameOptions);
154
+ /**
155
+ * Resolves once the renderer can draw; rejects, naming both Render
156
+ * Backends, when neither initializes. Every call returns the same promise.
157
+ * Frames before it simulate but draw nothing, so a host awaits it before
158
+ * its first frame (ADR 0025).
159
+ */
160
+ ready(): Promise<void>;
161
+ /** The Render Backend the renderer settled on; null until ready() resolved. */
162
+ get backend(): RenderBackend | null;
141
163
  /** Creates a live entity in the scene. */
142
164
  spawn(name: string): Entity;
143
165
  /** Instantiates a registered prefab after a scene has supplied the registry. */
@@ -205,6 +227,11 @@ export declare class Game {
205
227
  setViewHeight(value: number): void;
206
228
  /** Shuts the game down completely (loop, input, GPU). */
207
229
  dispose(): void;
230
+ /**
231
+ * three installs the loop once its init settled; a failed init is
232
+ * reported by ready(), not by every loop change made before it.
233
+ */
234
+ private setAnimationLoop;
208
235
  /**
209
236
  * Real-time playback for the Runtime Bridge: the same clock-driven loop
210
237
  * as start(), sharing the accumulator, with `onStep` told after every
@@ -248,8 +275,6 @@ export declare class Game {
248
275
  private runHostUpdates;
249
276
  private flushPendingSceneLoad;
250
277
  private unregisterRuntimeBridge;
251
- /** Under y-sort, re-derives every participant's z from layer band + entity Y. */
252
- private applyYSort;
253
278
  private renderSurface;
254
279
  private componentUpdateSchedule;
255
280
  private updateSceneCamera;