@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.
- package/README.md +24 -0
- package/dist/components/animated-sprite.d.ts +6 -0
- package/dist/components/animated-sprite.js +15 -1
- package/dist/components/light.d.ts +68 -0
- package/dist/components/light.js +59 -0
- package/dist/components/particle-emitter.d.ts +6 -0
- package/dist/components/particle-emitter.js +13 -0
- package/dist/components/sprite.d.ts +6 -0
- package/dist/components/sprite.js +15 -1
- package/dist/components/tilemap.js +6 -0
- package/dist/frame-quad.d.ts +21 -0
- package/dist/frame-quad.js +50 -0
- package/dist/game.d.ts +8 -0
- package/dist/game.js +20 -8
- package/dist/index.d.ts +11 -1
- package/dist/index.js +6 -0
- package/dist/light-field.d.ts +78 -0
- package/dist/light-field.js +145 -0
- package/dist/light-map-material.d.ts +40 -0
- package/dist/light-map-material.js +203 -0
- package/dist/light-map.d.ts +46 -0
- package/dist/light-map.js +132 -0
- package/dist/lit-frame.d.ts +48 -0
- package/dist/lit-frame.js +167 -0
- package/dist/occluder-grid.d.ts +19 -0
- package/dist/occluder-grid.js +50 -0
- package/dist/particle-batch.js +3 -2
- package/dist/post-effects.d.ts +33 -0
- package/dist/post-effects.js +48 -0
- package/dist/post-pass.d.ts +23 -0
- package/dist/post-pass.js +93 -0
- package/dist/render-layers.d.ts +18 -0
- package/dist/render-layers.js +19 -0
- package/dist/render-output.d.ts +7 -0
- package/dist/render-output.js +17 -1
- package/dist/runtime-inspection.d.ts +5 -0
- package/dist/runtime-inspection.js +5 -1
- package/dist/runtime-lighting-snapshot.d.ts +41 -0
- package/dist/runtime-lighting-snapshot.js +46 -0
- package/dist/scene-lighting.d.ts +52 -0
- package/dist/scene-lighting.js +107 -0
- package/dist/scene-render-options.d.ts +51 -0
- package/dist/scene-render-options.js +126 -0
- package/dist/scene.d.ts +29 -0
- package/dist/sprite-batch.js +5 -1
- 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
|
-
|
|
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';
|