@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.
- package/README.md +43 -0
- package/dist/assets/asset-loader.d.ts +1 -1
- package/dist/assets/asset-loader.js +1 -1
- package/dist/assets/texture-backend.d.ts +1 -1
- package/dist/assets/texture-backend.js +1 -1
- package/dist/camera.js +1 -1
- package/dist/components/animated-sprite.d.ts +6 -0
- package/dist/components/animated-sprite.js +16 -2
- 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 +16 -2
- package/dist/components/tilemap.js +9 -1
- package/dist/entity.d.ts +1 -1
- package/dist/entity.js +1 -1
- package/dist/frame-quad.d.ts +21 -0
- package/dist/frame-quad.js +50 -0
- package/dist/game.d.ts +28 -3
- package/dist/game.js +79 -52
- package/dist/index.d.ts +14 -3
- package/dist/index.js +9 -1
- 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.d.ts +1 -1
- package/dist/particle-batch.js +7 -3
- package/dist/particle-sort-bounds.d.ts +1 -1
- package/dist/particle-sort-bounds.js +1 -1
- package/dist/pointer.js +1 -1
- 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 +34 -0
- package/dist/render-output.js +60 -0
- package/dist/render-readiness.d.ts +37 -0
- package/dist/render-readiness.js +61 -0
- package/dist/render-sort.d.ts +14 -0
- package/dist/render-sort.js +37 -0
- package/dist/runtime-bridge.d.ts +18 -2
- package/dist/runtime-bridge.js +7 -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.d.ts +3 -3
- package/dist/sprite-batch.js +45 -29
- package/dist/sprite-batches.d.ts +2 -2
- package/dist/sprite-batches.js +1 -1
- package/dist/sprite-draw-collector.d.ts +3 -3
- package/dist/sprite-draw-collector.js +3 -3
- 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:
|
package/dist/camera.js
CHANGED
|
@@ -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
package/dist/entity.js
CHANGED
|
@@ -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;
|