@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { anchoredPiecesOf } from './ui.js';
|
|
2
2
|
import { componentClassOf } from './component-registry.js';
|
|
3
|
+
import { capLights, lightingSnapshot, postSnapshot, } from './runtime-lighting-snapshot.js';
|
|
3
4
|
export const RUNTIME_PROJECTION_LIMITS = {
|
|
4
5
|
depth: 5,
|
|
5
6
|
entries: 100,
|
|
@@ -293,6 +294,8 @@ export class RuntimeInspector {
|
|
|
293
294
|
time: this.timeSnapshot(),
|
|
294
295
|
ui: this.uiSnapshot(projectionIssues),
|
|
295
296
|
camera: this.game.cameraEffects.state,
|
|
297
|
+
lighting: lightingSnapshot(this.game, (entity) => this.idFor(entity)),
|
|
298
|
+
post: postSnapshot(this.game),
|
|
296
299
|
});
|
|
297
300
|
}
|
|
298
301
|
audioSnapshot() {
|
|
@@ -341,7 +344,8 @@ export class RuntimeInspector {
|
|
|
341
344
|
if (fitsSnapshot(capped))
|
|
342
345
|
return capped;
|
|
343
346
|
}
|
|
344
|
-
|
|
347
|
+
const anchoredCapped = capAnchored(capped);
|
|
348
|
+
return fitsSnapshot(anchoredCapped) ? anchoredCapped : capLights(anchoredCapped, fitsSnapshot);
|
|
345
349
|
}
|
|
346
350
|
idFor(entity) {
|
|
347
351
|
const existing = this.ids.get(entity);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Entity } from './entity.js';
|
|
2
|
+
import type { Game } from './game.js';
|
|
3
|
+
import type { ProjectionIssue } from './runtime-inspection.js';
|
|
4
|
+
import type { AmbientLight, PostEffectsState } from './scene-render-options.js';
|
|
5
|
+
/** One live Light as a Runtime Snapshot reports it: its entity's name, logical position and params. */
|
|
6
|
+
export interface RuntimeSnapshotLight {
|
|
7
|
+
/** The entity's name, as `ui.anchored[].entity` reports it. */
|
|
8
|
+
entity: string;
|
|
9
|
+
/** The entity's snapshot id (`entity-N`), unique where names repeat. */
|
|
10
|
+
id: string;
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
radius: number;
|
|
14
|
+
/** `#rrggbb`. */
|
|
15
|
+
color: string;
|
|
16
|
+
intensity: number;
|
|
17
|
+
bands: number;
|
|
18
|
+
softness: number;
|
|
19
|
+
castShadows: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** `game.lighting` (issue #78 CA-12): the Ambient Light now and every live Light, in logical coordinates. */
|
|
22
|
+
export interface RuntimeSnapshotLighting {
|
|
23
|
+
ambient: AmbientLight;
|
|
24
|
+
lights: RuntimeSnapshotLight[];
|
|
25
|
+
}
|
|
26
|
+
/** `game.post` (issue #78 CA-12): each Post Effect, null when off. */
|
|
27
|
+
export type RuntimeSnapshotPost = PostEffectsState;
|
|
28
|
+
export declare function lightingSnapshot(game: Game, idFor: (entity: Entity) => string): RuntimeSnapshotLighting;
|
|
29
|
+
export declare function postSnapshot(game: Game): RuntimeSnapshotPost;
|
|
30
|
+
/** What capping the lights needs from a snapshot: its lights and its projection issues. */
|
|
31
|
+
interface LightsCappable {
|
|
32
|
+
lighting: RuntimeSnapshotLighting;
|
|
33
|
+
projectionIssues: ProjectionIssue[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The global cap's last stage, once entities and Anchored Pieces are gone
|
|
37
|
+
* (review): drops lights from the end until the snapshot fits, recording how
|
|
38
|
+
* many went with a `truncated` marker at `lighting.lights[n]`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function capLights<T extends LightsCappable>(snapshot: T, fits: (candidate: T) => boolean): T;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const hex = (channel) => Math.round(channel * 255).toString(16).padStart(2, '0');
|
|
2
|
+
export function lightingSnapshot(game, idFor) {
|
|
3
|
+
return {
|
|
4
|
+
ambient: game.lighting.ambient,
|
|
5
|
+
lights: game.lighting.lights.map((light) => {
|
|
6
|
+
const field = light.field();
|
|
7
|
+
const [r, g, b] = field.color;
|
|
8
|
+
return {
|
|
9
|
+
entity: light.entity.name,
|
|
10
|
+
id: idFor(light.entity),
|
|
11
|
+
x: field.x,
|
|
12
|
+
y: field.y,
|
|
13
|
+
radius: field.radius,
|
|
14
|
+
color: `#${hex(r)}${hex(g)}${hex(b)}`,
|
|
15
|
+
intensity: field.intensity,
|
|
16
|
+
bands: field.bands,
|
|
17
|
+
softness: field.softness,
|
|
18
|
+
castShadows: field.castShadows,
|
|
19
|
+
};
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function postSnapshot(game) {
|
|
24
|
+
return { vignette: game.post.vignette, colorGrade: game.post.colorGrade };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The global cap's last stage, once entities and Anchored Pieces are gone
|
|
28
|
+
* (review): drops lights from the end until the snapshot fits, recording how
|
|
29
|
+
* many went with a `truncated` marker at `lighting.lights[n]`.
|
|
30
|
+
*/
|
|
31
|
+
export function capLights(snapshot, fits) {
|
|
32
|
+
let capped = snapshot;
|
|
33
|
+
const retained = [...snapshot.lighting.lights];
|
|
34
|
+
while (retained.length > 0) {
|
|
35
|
+
retained.pop();
|
|
36
|
+
const omitted = snapshot.lighting.lights.length - retained.length;
|
|
37
|
+
capped = {
|
|
38
|
+
...snapshot,
|
|
39
|
+
lighting: { ...snapshot.lighting, lights: [...retained] },
|
|
40
|
+
projectionIssues: [...snapshot.projectionIssues, { path: `lighting.lights[${retained.length}]`, marker: 'truncated', omitted }],
|
|
41
|
+
};
|
|
42
|
+
if (fits(capped))
|
|
43
|
+
return capped;
|
|
44
|
+
}
|
|
45
|
+
return capped;
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Light } from './components/light.js';
|
|
2
|
+
import type { Rgb } from './light-field.js';
|
|
3
|
+
import { type AmbientLight } from './scene-render-options.js';
|
|
4
|
+
import type { SceneRenderJson } from './scene.js';
|
|
5
|
+
/** What `game.lighting.ambient` accepts: either field, a color as `#rrggbb` or a number. */
|
|
6
|
+
export interface AmbientLightInput {
|
|
7
|
+
color?: string | number;
|
|
8
|
+
intensity?: number;
|
|
9
|
+
}
|
|
10
|
+
/** A `#rrggbb` color's three channels in 0..1. */
|
|
11
|
+
export declare function hexChannels(hex: string): Rgb;
|
|
12
|
+
/**
|
|
13
|
+
* `game.lighting` (issue #78, ADR 0026): the live scene's Ambient Light and
|
|
14
|
+
* its Lights. A scene is drawn lit while it declares `render.lighting`, a
|
|
15
|
+
* game sets the ambient at runtime, or a Light lives in it; otherwise it is
|
|
16
|
+
* drawn exactly as an unlit scene. Everything here dies with its scene.
|
|
17
|
+
*/
|
|
18
|
+
export declare class GameLighting {
|
|
19
|
+
constructor();
|
|
20
|
+
/** The Ambient Light now, a copy: color `#rrggbb`, intensity 0..1. */
|
|
21
|
+
get ambient(): AmbientLight;
|
|
22
|
+
/**
|
|
23
|
+
* Changes the Ambient Light, field by field — tween it with `game.time.tween`
|
|
24
|
+
* for a day/night cycle. Out-of-range values clamp; an unreadable color is
|
|
25
|
+
* ignored. Setting it lights a scene that declared no lighting.
|
|
26
|
+
*/
|
|
27
|
+
set ambient(value: AmbientLightInput);
|
|
28
|
+
/** Whether the live scene is drawn lit. */
|
|
29
|
+
get active(): boolean;
|
|
30
|
+
/** Every live Light, in the order they were spawned. */
|
|
31
|
+
get lights(): readonly Light[];
|
|
32
|
+
/** Adopts a scene's `render.lighting`, replacing whatever the previous scene left. Called by Game. */
|
|
33
|
+
loadScene(render: SceneRenderJson | undefined): void;
|
|
34
|
+
/** Back to an unlit scene's state: full white, undeclared. Its Lights leave with their entities. */
|
|
35
|
+
unloadScene(): void;
|
|
36
|
+
}
|
|
37
|
+
/** The Ambient Light as light-map multipliers: its color's channels × its intensity. */
|
|
38
|
+
export declare function ambientMultiplier(lighting: GameLighting): Rgb;
|
|
39
|
+
/**
|
|
40
|
+
* Whether the Ambient Light leaves the art as drawn: lights only add and the
|
|
41
|
+
* light-map clamps at 1, so such a scene's light-map is 1 everywhere and the
|
|
42
|
+
* frame is drawn unlit, building nothing (review).
|
|
43
|
+
*/
|
|
44
|
+
export declare function ambientIsFull(lighting: GameLighting): boolean;
|
|
45
|
+
/** Internal: a Light joins its scene's lighting when it is ready. */
|
|
46
|
+
export declare function addLight(lighting: GameLighting, light: Light): void;
|
|
47
|
+
/** Internal: a destroyed Light leaves at once, so the next drawn frame no longer has it. */
|
|
48
|
+
export declare function removeLight(lighting: GameLighting, light: Light): void;
|
|
49
|
+
/** Internal: a Tilemap's solid tiles changed (spawned, edited or destroyed); occlusion is rebuilt once. */
|
|
50
|
+
export declare function markOccludersChanged(lighting: GameLighting | undefined): void;
|
|
51
|
+
/** Internal: bumps whenever the solid tiles change, so the occluder grid is rebuilt only then (inference 9). */
|
|
52
|
+
export declare function occluderRevision(lighting: GameLighting): number;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { hexColorOr, resolveAmbientLight } from './scene-render-options.js';
|
|
2
|
+
const states = new WeakMap();
|
|
3
|
+
function stateOf(lighting) {
|
|
4
|
+
const state = states.get(lighting);
|
|
5
|
+
if (!state)
|
|
6
|
+
throw new Error('a GameLighting was used before it was constructed');
|
|
7
|
+
return state;
|
|
8
|
+
}
|
|
9
|
+
/** `0xrrggbb` as `#rrggbb`; anything that is not a 24-bit integer as undefined. */
|
|
10
|
+
function hexOfNumber(value) {
|
|
11
|
+
if (!Number.isInteger(value) || value < 0 || value > 0xffffff)
|
|
12
|
+
return undefined;
|
|
13
|
+
return `#${value.toString(16).padStart(6, '0')}`;
|
|
14
|
+
}
|
|
15
|
+
/** A `#rrggbb` color's three channels in 0..1. */
|
|
16
|
+
export function hexChannels(hex) {
|
|
17
|
+
const value = Number.parseInt(hex.slice(1), 16);
|
|
18
|
+
return [((value >> 16) & 0xff) / 255, ((value >> 8) & 0xff) / 255, (value & 0xff) / 255];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* `game.lighting` (issue #78, ADR 0026): the live scene's Ambient Light and
|
|
22
|
+
* its Lights. A scene is drawn lit while it declares `render.lighting`, a
|
|
23
|
+
* game sets the ambient at runtime, or a Light lives in it; otherwise it is
|
|
24
|
+
* drawn exactly as an unlit scene. Everything here dies with its scene.
|
|
25
|
+
*/
|
|
26
|
+
export class GameLighting {
|
|
27
|
+
constructor() {
|
|
28
|
+
states.set(this, { ambient: resolveAmbientLight(undefined), declared: false, lights: [], occluderRevision: 0 });
|
|
29
|
+
}
|
|
30
|
+
/** The Ambient Light now, a copy: color `#rrggbb`, intensity 0..1. */
|
|
31
|
+
get ambient() {
|
|
32
|
+
const { ambient } = stateOf(this);
|
|
33
|
+
return { color: ambient.color, intensity: ambient.intensity };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Changes the Ambient Light, field by field — tween it with `game.time.tween`
|
|
37
|
+
* for a day/night cycle. Out-of-range values clamp; an unreadable color is
|
|
38
|
+
* ignored. Setting it lights a scene that declared no lighting.
|
|
39
|
+
*/
|
|
40
|
+
set ambient(value) {
|
|
41
|
+
const state = stateOf(this);
|
|
42
|
+
const color = typeof value.color === 'number' ? hexOfNumber(value.color) : value.color;
|
|
43
|
+
// An unreadable intensity (a NaN from a custom easing, say) is ignored, like an unreadable color.
|
|
44
|
+
const intensity = typeof value.intensity === 'number' && Number.isFinite(value.intensity) ? value.intensity : state.ambient.intensity;
|
|
45
|
+
state.ambient = resolveAmbientLight({
|
|
46
|
+
ambient: { color: hexColorOr(color, state.ambient.color), intensity },
|
|
47
|
+
});
|
|
48
|
+
state.declared = true;
|
|
49
|
+
}
|
|
50
|
+
/** Whether the live scene is drawn lit. */
|
|
51
|
+
get active() {
|
|
52
|
+
const state = stateOf(this);
|
|
53
|
+
return state.declared || state.lights.length > 0;
|
|
54
|
+
}
|
|
55
|
+
/** Every live Light, in the order they were spawned. */
|
|
56
|
+
get lights() {
|
|
57
|
+
return [...stateOf(this).lights];
|
|
58
|
+
}
|
|
59
|
+
/** Adopts a scene's `render.lighting`, replacing whatever the previous scene left. Called by Game. */
|
|
60
|
+
loadScene(render) {
|
|
61
|
+
const state = stateOf(this);
|
|
62
|
+
state.ambient = resolveAmbientLight(render?.lighting);
|
|
63
|
+
state.declared = render?.lighting !== undefined;
|
|
64
|
+
}
|
|
65
|
+
/** Back to an unlit scene's state: full white, undeclared. Its Lights leave with their entities. */
|
|
66
|
+
unloadScene() {
|
|
67
|
+
this.loadScene(undefined);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** The Ambient Light as light-map multipliers: its color's channels × its intensity. */
|
|
71
|
+
export function ambientMultiplier(lighting) {
|
|
72
|
+
const { color, intensity } = stateOf(lighting).ambient;
|
|
73
|
+
const [r, g, b] = hexChannels(color);
|
|
74
|
+
return [r * intensity, g * intensity, b * intensity];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Whether the Ambient Light leaves the art as drawn: lights only add and the
|
|
78
|
+
* light-map clamps at 1, so such a scene's light-map is 1 everywhere and the
|
|
79
|
+
* frame is drawn unlit, building nothing (review).
|
|
80
|
+
*/
|
|
81
|
+
export function ambientIsFull(lighting) {
|
|
82
|
+
return ambientMultiplier(lighting).every((channel) => channel >= 1);
|
|
83
|
+
}
|
|
84
|
+
/** Internal: a Light joins its scene's lighting when it is ready. */
|
|
85
|
+
export function addLight(lighting, light) {
|
|
86
|
+
const { lights } = stateOf(lighting);
|
|
87
|
+
if (!lights.includes(light))
|
|
88
|
+
lights.push(light);
|
|
89
|
+
}
|
|
90
|
+
/** Internal: a destroyed Light leaves at once, so the next drawn frame no longer has it. */
|
|
91
|
+
export function removeLight(lighting, light) {
|
|
92
|
+
const { lights } = stateOf(lighting);
|
|
93
|
+
const index = lights.indexOf(light);
|
|
94
|
+
if (index !== -1)
|
|
95
|
+
lights.splice(index, 1);
|
|
96
|
+
}
|
|
97
|
+
/** Internal: a Tilemap's solid tiles changed (spawned, edited or destroyed); occlusion is rebuilt once. */
|
|
98
|
+
export function markOccludersChanged(lighting) {
|
|
99
|
+
// A Tilemap on a stand-in game (a test stub with no lighting) has nothing to notify.
|
|
100
|
+
const state = lighting ? states.get(lighting) : undefined;
|
|
101
|
+
if (state)
|
|
102
|
+
state.occluderRevision += 1;
|
|
103
|
+
}
|
|
104
|
+
/** Internal: bumps whenever the solid tiles change, so the occluder grid is rebuilt only then (inference 9). */
|
|
105
|
+
export function occluderRevision(lighting) {
|
|
106
|
+
return stateOf(lighting).occluderRevision;
|
|
107
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SceneLightingJson, ScenePostJson } from './scene.js';
|
|
2
|
+
/** The Ambient Light: a `#rrggbb` color and an intensity in 0..1. */
|
|
3
|
+
export interface AmbientLight {
|
|
4
|
+
color: string;
|
|
5
|
+
intensity: number;
|
|
6
|
+
}
|
|
7
|
+
export interface VignetteEffect {
|
|
8
|
+
/** How dark the corners get: 0 none, 1 black. */
|
|
9
|
+
intensity: number;
|
|
10
|
+
/** Where the darkening starts, from the centre (0) to the corners (1). */
|
|
11
|
+
radius: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ColorGradeEffect {
|
|
14
|
+
/** A `#rrggbb` multiplier over the frame. */
|
|
15
|
+
tint: string;
|
|
16
|
+
/** 1 leaves contrast unchanged; 0 flattens to grey, 2 doubles it. */
|
|
17
|
+
contrast: number;
|
|
18
|
+
/** 1 leaves saturation unchanged; 0 is greyscale, 2 doubles it. */
|
|
19
|
+
saturation: number;
|
|
20
|
+
}
|
|
21
|
+
/** The scene's Post Effects; null is an effect that is off. */
|
|
22
|
+
export interface PostEffectsState {
|
|
23
|
+
vignette: VignetteEffect | null;
|
|
24
|
+
colorGrade: ColorGradeEffect | null;
|
|
25
|
+
}
|
|
26
|
+
/** One field of a scene's `render` block that is outside its range, for validate_project. */
|
|
27
|
+
export interface SceneRenderIssue {
|
|
28
|
+
field: string;
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
/** A `#rrggbb` color in lower case, or `fallback` for anything else. */
|
|
32
|
+
export declare function hexColorOr(value: unknown, fallback: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* The scene's starting Ambient Light: full white light unless `render.lighting.ambient`
|
|
35
|
+
* says otherwise. Runtime clamping (inference 11): an out-of-range value is
|
|
36
|
+
* clamped and an unreadable one falls back to its default, never thrown.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveAmbientLight(json: SceneLightingJson | undefined): AmbientLight;
|
|
39
|
+
/** A vignette as the runtime applies it, clamped; null when the scene has none. */
|
|
40
|
+
export declare function resolveVignette(value: unknown): VignetteEffect | null;
|
|
41
|
+
/** A color grade as the runtime applies it, absent fields neutral; null when the scene has none. */
|
|
42
|
+
export declare function resolveColorGrade(value: unknown): ColorGradeEffect | null;
|
|
43
|
+
/** The scene's starting Post Effects: every one off unless `render.post` turns it on. */
|
|
44
|
+
export declare function resolvePostEffects(json: ScenePostJson | undefined): PostEffectsState;
|
|
45
|
+
/**
|
|
46
|
+
* Every field of a scene's `render.lighting` and `render.post` that is
|
|
47
|
+
* outside its range, each with a message naming the field and its range —
|
|
48
|
+
* what the MCP scene validation reports (CA-2). Other render fields are not
|
|
49
|
+
* checked here.
|
|
50
|
+
*/
|
|
51
|
+
export declare function sceneRenderIssues(render: unknown): SceneRenderIssue[];
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
const WHITE = '#ffffff';
|
|
2
|
+
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
|
|
3
|
+
/** Each numeric field's range (inference 12). */
|
|
4
|
+
const RANGES = {
|
|
5
|
+
'render.lighting.ambient.intensity': [0, 1],
|
|
6
|
+
'render.post.vignette.intensity': [0, 1],
|
|
7
|
+
'render.post.vignette.radius': [0, 1],
|
|
8
|
+
'render.post.colorGrade.contrast': [0, 2],
|
|
9
|
+
'render.post.colorGrade.saturation': [0, 2],
|
|
10
|
+
};
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function recordOf(value) {
|
|
15
|
+
return isRecord(value) ? value : {};
|
|
16
|
+
}
|
|
17
|
+
/** A `#rrggbb` color in lower case, or `fallback` for anything else. */
|
|
18
|
+
export function hexColorOr(value, fallback) {
|
|
19
|
+
return typeof value === 'string' && HEX_COLOR.test(value) ? value.toLowerCase() : fallback;
|
|
20
|
+
}
|
|
21
|
+
/** A finite number clamped to the field's range, or `fallback` for anything else. */
|
|
22
|
+
function rangedOr(value, field, fallback) {
|
|
23
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
24
|
+
return fallback;
|
|
25
|
+
const [min, max] = RANGES[field];
|
|
26
|
+
return Math.min(max, Math.max(min, value));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The scene's starting Ambient Light: full white light unless `render.lighting.ambient`
|
|
30
|
+
* says otherwise. Runtime clamping (inference 11): an out-of-range value is
|
|
31
|
+
* clamped and an unreadable one falls back to its default, never thrown.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveAmbientLight(json) {
|
|
34
|
+
const ambient = recordOf(recordOf(json).ambient);
|
|
35
|
+
return {
|
|
36
|
+
color: hexColorOr(ambient.color, WHITE),
|
|
37
|
+
intensity: rangedOr(ambient.intensity, 'render.lighting.ambient.intensity', 1),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** A vignette as the runtime applies it, clamped; null when the scene has none. */
|
|
41
|
+
export function resolveVignette(value) {
|
|
42
|
+
if (!isRecord(value))
|
|
43
|
+
return null;
|
|
44
|
+
return {
|
|
45
|
+
intensity: rangedOr(value.intensity, 'render.post.vignette.intensity', 0.5),
|
|
46
|
+
radius: rangedOr(value.radius, 'render.post.vignette.radius', 0.5),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** A color grade as the runtime applies it, absent fields neutral; null when the scene has none. */
|
|
50
|
+
export function resolveColorGrade(value) {
|
|
51
|
+
if (!isRecord(value))
|
|
52
|
+
return null;
|
|
53
|
+
return {
|
|
54
|
+
tint: hexColorOr(value.tint, WHITE),
|
|
55
|
+
contrast: rangedOr(value.contrast, 'render.post.colorGrade.contrast', 1),
|
|
56
|
+
saturation: rangedOr(value.saturation, 'render.post.colorGrade.saturation', 1),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** The scene's starting Post Effects: every one off unless `render.post` turns it on. */
|
|
60
|
+
export function resolvePostEffects(json) {
|
|
61
|
+
const post = recordOf(json);
|
|
62
|
+
return { vignette: resolveVignette(post.vignette), colorGrade: resolveColorGrade(post.colorGrade) };
|
|
63
|
+
}
|
|
64
|
+
const shown = (value) => (typeof value === 'string' ? JSON.stringify(value) : String(value));
|
|
65
|
+
function rangeIssue(field, value, issues) {
|
|
66
|
+
const [min, max] = RANGES[field];
|
|
67
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max)
|
|
68
|
+
return;
|
|
69
|
+
issues.push({ field, message: `${field} must be a number from ${min} to ${max}; got ${shown(value)}.` });
|
|
70
|
+
}
|
|
71
|
+
function colorIssue(field, value, issues) {
|
|
72
|
+
if (value === undefined || (typeof value === 'string' && HEX_COLOR.test(value)))
|
|
73
|
+
return;
|
|
74
|
+
issues.push({ field, message: `${field} must be a #rrggbb color; got ${shown(value)}.` });
|
|
75
|
+
}
|
|
76
|
+
/** The block at `field` (its last segment, in `parent`), reported when present but not an object. */
|
|
77
|
+
function block(parent, field, issues) {
|
|
78
|
+
const value = parent[field.slice(field.lastIndexOf('.') + 1)];
|
|
79
|
+
if (value === undefined)
|
|
80
|
+
return undefined;
|
|
81
|
+
if (isRecord(value))
|
|
82
|
+
return value;
|
|
83
|
+
issues.push({ field, message: `${field} must be an object; got ${shown(value)}.` });
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
function lightingIssues(render, issues) {
|
|
87
|
+
const lighting = block(render, 'render.lighting', issues);
|
|
88
|
+
const ambient = lighting && block(lighting, 'render.lighting.ambient', issues);
|
|
89
|
+
if (!ambient)
|
|
90
|
+
return;
|
|
91
|
+
colorIssue('render.lighting.ambient.color', ambient.color, issues);
|
|
92
|
+
if (ambient.intensity !== undefined)
|
|
93
|
+
rangeIssue('render.lighting.ambient.intensity', ambient.intensity, issues);
|
|
94
|
+
}
|
|
95
|
+
function postIssues(render, issues) {
|
|
96
|
+
const post = block(render, 'render.post', issues);
|
|
97
|
+
if (!post)
|
|
98
|
+
return;
|
|
99
|
+
const vignette = block(post, 'render.post.vignette', issues);
|
|
100
|
+
if (vignette) {
|
|
101
|
+
rangeIssue('render.post.vignette.intensity', vignette.intensity, issues);
|
|
102
|
+
rangeIssue('render.post.vignette.radius', vignette.radius, issues);
|
|
103
|
+
}
|
|
104
|
+
const grade = block(post, 'render.post.colorGrade', issues);
|
|
105
|
+
if (!grade)
|
|
106
|
+
return;
|
|
107
|
+
colorIssue('render.post.colorGrade.tint', grade.tint, issues);
|
|
108
|
+
if (grade.contrast !== undefined)
|
|
109
|
+
rangeIssue('render.post.colorGrade.contrast', grade.contrast, issues);
|
|
110
|
+
if (grade.saturation !== undefined)
|
|
111
|
+
rangeIssue('render.post.colorGrade.saturation', grade.saturation, issues);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Every field of a scene's `render.lighting` and `render.post` that is
|
|
115
|
+
* outside its range, each with a message naming the field and its range —
|
|
116
|
+
* what the MCP scene validation reports (CA-2). Other render fields are not
|
|
117
|
+
* checked here.
|
|
118
|
+
*/
|
|
119
|
+
export function sceneRenderIssues(render) {
|
|
120
|
+
if (!isRecord(render))
|
|
121
|
+
return [];
|
|
122
|
+
const issues = [];
|
|
123
|
+
lightingIssues(render, issues);
|
|
124
|
+
postIssues(render, issues);
|
|
125
|
+
return issues;
|
|
126
|
+
}
|
package/dist/scene.d.ts
CHANGED
|
@@ -28,8 +28,37 @@ export interface PrefabJson {
|
|
|
28
28
|
type: 'character' | 'object' | 'tile';
|
|
29
29
|
components: SceneComponentJson[];
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* The scene's lighting (issue #78, ADR 0026). Declaring it — or spawning a
|
|
33
|
+
* Light — draws the scene lit; absent, the scene is drawn exactly as unlit.
|
|
34
|
+
*/
|
|
35
|
+
export interface SceneLightingJson {
|
|
36
|
+
/** Where no light reaches. Absent fields: `#ffffff` and 1, the art as drawn. */
|
|
37
|
+
ambient?: {
|
|
38
|
+
color?: string;
|
|
39
|
+
intensity?: number;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** The scene's Post Effects, each off unless declared (issue #78). */
|
|
43
|
+
export interface ScenePostJson {
|
|
44
|
+
/** Darkens the frame's edges: `intensity` 0..1 at the corners, starting at `radius` 0..1 from the centre. */
|
|
45
|
+
vignette?: {
|
|
46
|
+
intensity: number;
|
|
47
|
+
radius: number;
|
|
48
|
+
};
|
|
49
|
+
/** A `#rrggbb` tint multiplier, and contrast and saturation from 0 to 2 (1 leaves the frame unchanged). */
|
|
50
|
+
colorGrade?: {
|
|
51
|
+
tint?: string;
|
|
52
|
+
contrast?: number;
|
|
53
|
+
saturation?: number;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
31
56
|
/** Scene-wide render options (v3). */
|
|
32
57
|
export interface SceneRenderJson {
|
|
58
|
+
/** Ambient Light; with it (or any Light) the scene is lit (ADR 0026). */
|
|
59
|
+
lighting?: SceneLightingJson;
|
|
60
|
+
/** Vignette and color grade; with either on, the frame renders through a render target. */
|
|
61
|
+
post?: ScenePostJson;
|
|
33
62
|
/**
|
|
34
63
|
* 'y' orders same-layer sprites by their render-space Y — lower Y renders
|
|
35
64
|
* in front (top-down depth). Absent: spawn order breaks same-layer ties.
|
package/dist/sprite-batch.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as THREE from 'three';
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
2
|
import type { TextureOutcome } from './assets/asset-loader.js';
|
|
3
3
|
export type SpriteBatchShape = 'rectangle' | 'circle';
|
|
4
4
|
/**
|
|
@@ -44,7 +44,7 @@ export declare class SpriteRunMesh {
|
|
|
44
44
|
readonly mesh: THREE.InstancedMesh;
|
|
45
45
|
private readonly colors;
|
|
46
46
|
private readonly uvs;
|
|
47
|
-
constructor(base: THREE.BufferGeometry, material: THREE.
|
|
47
|
+
constructor(base: THREE.BufferGeometry, material: THREE.MeshBasicNodeMaterial, capacity: number);
|
|
48
48
|
get capacity(): number;
|
|
49
49
|
/** Writes the sprite drawn `index`-th in this run. */
|
|
50
50
|
write(index: number, instance: SpriteBatchInstance): void;
|
|
@@ -61,7 +61,7 @@ export declare class SpriteRunMesh {
|
|
|
61
61
|
*/
|
|
62
62
|
export declare class SpriteBatch {
|
|
63
63
|
private readonly geometry;
|
|
64
|
-
readonly material: THREE.
|
|
64
|
+
readonly material: THREE.MeshBasicNodeMaterial;
|
|
65
65
|
/** When the key's texture settled; null for untextured and bare entries. */
|
|
66
66
|
readonly settled: Promise<TextureOutcome> | null;
|
|
67
67
|
private readonly slots;
|
package/dist/sprite-batch.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { attribute, context, mat3, positionLocal, uv, vec3, vertexStage } from 'three/tsl';
|
|
2
|
+
import * as THREE from 'three/webgpu';
|
|
3
|
+
import { TRANSPARENT_TEXEL_ALPHA } from './render-layers.js';
|
|
2
4
|
/** The first capacity of a batch's instance buffers; it doubles when full. */
|
|
3
5
|
export const INITIAL_SPRITE_BATCH_CAPACITY = 16;
|
|
4
6
|
/** The key's canonical string: equal keys share one batch. */
|
|
@@ -12,41 +14,52 @@ export function spriteGeometry(shape) {
|
|
|
12
14
|
}
|
|
13
15
|
/** The per-instance UV transform a run carries: repeat (x, y) and offset (z, w). */
|
|
14
16
|
const INSTANCE_UV = 'instanceUv';
|
|
17
|
+
/** Whether three is building the material for one of the batch's instanced run meshes. */
|
|
18
|
+
function drawsInstances(builder) {
|
|
19
|
+
return builder.object.isInstancedMesh === true;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The map UV of one instance: its repeat and offset as the same mat3 a
|
|
23
|
+
* texture's own matrix is, applied to the quad's UV per vertex — as every
|
|
24
|
+
* other map is (render-output.ts `mapUvPerVertex`) — so the corner UVs are
|
|
25
|
+
* the very floats the per-sprite draw interpolates.
|
|
26
|
+
*/
|
|
27
|
+
function instanceMapUv() {
|
|
28
|
+
const transform = attribute(INSTANCE_UV, 'vec4');
|
|
29
|
+
return vertexStage(mat3(transform.x, 0, 0, 0, transform.y, 0, transform.z, transform.w, 1).mul(vec3(uv(), 1)).xy);
|
|
30
|
+
}
|
|
15
31
|
/**
|
|
16
|
-
*
|
|
17
|
-
* holds view × world (three's own `modelViewMatrix` for that
|
|
18
|
-
* the same way on the CPU), so
|
|
19
|
-
* map's UV transform comes from the instance instead
|
|
20
|
-
*
|
|
21
|
-
* and if one were ever drawn as a plain mesh it
|
|
32
|
+
* Every batch's material, as a node material (ADR 0025). The instance
|
|
33
|
+
* matrix already holds view × world (three's own `modelViewMatrix` for that
|
|
34
|
+
* sprite, computed the same way on the CPU), so an instanced draw skips
|
|
35
|
+
* `modelViewMatrix`; the map's UV transform comes from the instance instead
|
|
36
|
+
* of the texture. Both apply to instanced draws only: the hidden anchors
|
|
37
|
+
* share this material, and if one were ever drawn as a plain mesh it gets
|
|
38
|
+
* three's own transforms.
|
|
22
39
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
'
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
' #include <project_vertex>',
|
|
41
|
-
'#endif',
|
|
42
|
-
].join('\n'));
|
|
40
|
+
class SpriteBatchMaterial extends THREE.MeshBasicNodeMaterial {
|
|
41
|
+
constructor(parameters) {
|
|
42
|
+
super(parameters);
|
|
43
|
+
this.contextNode = context({
|
|
44
|
+
getUV: (texture, builder) => {
|
|
45
|
+
if (!drawsInstances(builder))
|
|
46
|
+
return null;
|
|
47
|
+
// The instance transform replaces the map's own matrix.
|
|
48
|
+
texture.setUpdateMatrix(false);
|
|
49
|
+
return instanceMapUv();
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
setupPositionView(builder) {
|
|
54
|
+
// three's instancing already multiplied positionLocal by the instance matrix.
|
|
55
|
+
return drawsInstances(builder) ? positionLocal : super.setupPositionView(builder);
|
|
56
|
+
}
|
|
43
57
|
}
|
|
44
58
|
function createMaterial(key, textures) {
|
|
45
59
|
// Double-sided: a flipped sprite mirrors through a negative x scale in its
|
|
46
60
|
// matrix, exactly like its own mesh; per instance there is no winding flip.
|
|
47
|
-
const material = new
|
|
61
|
+
const material = new SpriteBatchMaterial({ transparent: true, side: THREE.DoubleSide, alphaTest: TRANSPARENT_TEXEL_ALPHA });
|
|
48
62
|
material.forceSinglePass = true;
|
|
49
|
-
material.onBeforeCompile = useInstanceTransforms;
|
|
50
63
|
if (key.texture === null)
|
|
51
64
|
return { material, settled: null };
|
|
52
65
|
const { texture, settled } = key.texture
|
|
@@ -88,6 +101,9 @@ export class SpriteRunMesh {
|
|
|
88
101
|
this.mesh.instanceColor = this.colors;
|
|
89
102
|
// Drawn in an explicit renderOrder; its instances are already in view space.
|
|
90
103
|
this.mesh.frustumCulled = false;
|
|
104
|
+
// A run is collected through its sprites' anchors, which carry their own
|
|
105
|
+
// layers (Emissive or not, ADR 0026): the run itself is on every layer.
|
|
106
|
+
this.mesh.layers.enableAll();
|
|
91
107
|
this.mesh.matrixAutoUpdate = false;
|
|
92
108
|
this.mesh.boundingSphere = new THREE.Sphere();
|
|
93
109
|
}
|
package/dist/sprite-batches.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as THREE from 'three';
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
2
|
import type { TextureOutcome } from './assets/asset-loader.js';
|
|
3
3
|
import type { Game } from './game.js';
|
|
4
4
|
import { SpriteBatch, type SpriteBatchInstance, type SpriteBatchKey, type SpriteBatchTextures } from './sprite-batch.js';
|
|
@@ -7,7 +7,7 @@ export interface SpriteUvSource {
|
|
|
7
7
|
readonly offset: THREE.Vector2;
|
|
8
8
|
readonly repeat: THREE.Vector2;
|
|
9
9
|
}
|
|
10
|
-
export type SpriteAnchor = THREE.Mesh<THREE.BufferGeometry, THREE.
|
|
10
|
+
export type SpriteAnchor = THREE.Mesh<THREE.BufferGeometry, THREE.MeshBasicNodeMaterial>;
|
|
11
11
|
/** Where an instance sits: its key, that key's batch and its slot there. */
|
|
12
12
|
export interface SpriteBatchSeat {
|
|
13
13
|
key: SpriteBatchKey;
|
package/dist/sprite-batches.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as THREE from 'three';
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
2
|
import { SpriteBatch, spriteBatchKeyId, spriteGeometry, } from './sprite-batch.js';
|
|
3
3
|
import { TransparentDrawCollector } from './sprite-draw-collector.js';
|
|
4
4
|
import { compareTransparentDraws, forEachSpriteRun } from './sprite-runs.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as THREE from 'three';
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
2
|
import type { Drawable } from './sprite-runs.js';
|
|
3
3
|
/** A frame drawable: what three sorts it by, the object, and the sprite instance it stands for (null otherwise). */
|
|
4
4
|
export interface FrameDrawable<K, I> extends Drawable<K> {
|
|
@@ -12,8 +12,8 @@ export interface CollectedSprites<K, I> {
|
|
|
12
12
|
keyOf(instance: I): K;
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
|
-
* One frame's transparent drawables, collected the way
|
|
16
|
-
*
|
|
15
|
+
* One frame's transparent drawables, collected the way three's renderer
|
|
16
|
+
* (`_projectObject`) walks the scene: visibility, camera layers, Group render
|
|
17
17
|
* order, frustum culling, and the clip-space z it sorts by. Sprite anchors
|
|
18
18
|
* are hidden yet collected when in view; `skip` (the batches' own run
|
|
19
19
|
* meshes) is never walked. The drawables are pooled across frames.
|