@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
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
import type { Game, GameResolution } from './game.js';
|
|
3
|
+
import type { SpriteBatches } from './sprite-batches.js';
|
|
4
|
+
/** What a frame is drawn with besides the Game's public state: its renderer and Sprite Batches. */
|
|
5
|
+
export interface FrameSurface {
|
|
6
|
+
readonly game: Game;
|
|
7
|
+
readonly renderer: THREE.WebGPURenderer;
|
|
8
|
+
readonly spriteBatches: SpriteBatches;
|
|
9
|
+
/** The fixed internal resolution, null to fill the canvas. */
|
|
10
|
+
readonly resolution: GameResolution | null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* What the occluder grid is built from: the solid tiles' revision and every
|
|
14
|
+
* Tilemap's origin, so moving a Tilemap rebuilds it too (review) — still
|
|
15
|
+
* never per frame while nothing changes.
|
|
16
|
+
*/
|
|
17
|
+
export declare function occluderSignature(game: Game): string;
|
|
18
|
+
/**
|
|
19
|
+
* Draws one frame (issue #78, ADR 0026). Off path (B4): an unlit scene with
|
|
20
|
+
* no Post Effect is one render straight into the canvas, exactly as before,
|
|
21
|
+
* and nothing here is ever built. Lit: the scene without its Emissive
|
|
22
|
+
* drawables, then the light-map multiplied over it, then the Emissive
|
|
23
|
+
* drawables. With a Post Effect, all of that goes into a target drawn like the
|
|
24
|
+
* canvas and
|
|
25
|
+
* one pass writes it to the canvas.
|
|
26
|
+
*/
|
|
27
|
+
export declare class FrameComposer {
|
|
28
|
+
private readonly surface;
|
|
29
|
+
private lightMap;
|
|
30
|
+
private postPass;
|
|
31
|
+
private readonly size;
|
|
32
|
+
constructor(surface: FrameSurface);
|
|
33
|
+
draw(): void;
|
|
34
|
+
dispose(): void;
|
|
35
|
+
/** The light-map's (and the Post Effects') size: the internal resolution, else the drawing buffer (B2). */
|
|
36
|
+
private frameSize;
|
|
37
|
+
/** Letterbox bars: clear the whole canvas, then render inside the scissor. */
|
|
38
|
+
private clearLetterbox;
|
|
39
|
+
/** The scene through its Sprite Batches (ADR 0024), for the camera's current layers. */
|
|
40
|
+
private drawScene;
|
|
41
|
+
/** The scene for one layer mask, restoring the camera's own mask after. */
|
|
42
|
+
private drawLayers;
|
|
43
|
+
/** Scene, light-map, Emissive drawables, into whatever target is bound now. */
|
|
44
|
+
private drawLit;
|
|
45
|
+
private composeLight;
|
|
46
|
+
/** The frame into the Post Effects' canvas-like target, then one pass to the canvas (CA-11). */
|
|
47
|
+
private drawThroughPost;
|
|
48
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
import { Tilemap } from './components/tilemap.js';
|
|
3
|
+
import { LightMap } from './light-map.js';
|
|
4
|
+
import { buildOccluderGrid } from './occluder-grid.js';
|
|
5
|
+
import { PostPass } from './post-pass.js';
|
|
6
|
+
import { EMISSIVE_LAYER } from './render-layers.js';
|
|
7
|
+
import { ambientIsFull, ambientMultiplier, occluderRevision } from './scene-lighting.js';
|
|
8
|
+
/** Layer 0 alone: every drawable that is not Emissive. */
|
|
9
|
+
const LIT_MASK = 1;
|
|
10
|
+
const EMISSIVE_MASK = 1 << EMISSIVE_LAYER;
|
|
11
|
+
/**
|
|
12
|
+
* What the occluder grid is built from: the solid tiles' revision and every
|
|
13
|
+
* Tilemap's origin, so moving a Tilemap rebuilds it too (review) — still
|
|
14
|
+
* never per frame while nothing changes.
|
|
15
|
+
*/
|
|
16
|
+
export function occluderSignature(game) {
|
|
17
|
+
const origins = occluderSources(game.entities).map(({ originX, originY }) => `${originX},${originY}`);
|
|
18
|
+
return `${occluderRevision(game.lighting)}|${origins.join(';')}`;
|
|
19
|
+
}
|
|
20
|
+
/** Every Tilemap of the scene as an occluder source (inference 8). */
|
|
21
|
+
function occluderSources(entities) {
|
|
22
|
+
const sources = [];
|
|
23
|
+
for (const entity of entities) {
|
|
24
|
+
for (const component of entity.components) {
|
|
25
|
+
if (!(component instanceof Tilemap))
|
|
26
|
+
continue;
|
|
27
|
+
sources.push({
|
|
28
|
+
originX: entity.position.x,
|
|
29
|
+
originY: entity.position.y,
|
|
30
|
+
cellSize: component.cellSize,
|
|
31
|
+
mapWidth: component.mapWidth,
|
|
32
|
+
mapHeight: component.mapHeight,
|
|
33
|
+
cells: component.cells,
|
|
34
|
+
solidTiles: component.solidTiles,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return sources;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Draws one frame (issue #78, ADR 0026). Off path (B4): an unlit scene with
|
|
42
|
+
* no Post Effect is one render straight into the canvas, exactly as before,
|
|
43
|
+
* and nothing here is ever built. Lit: the scene without its Emissive
|
|
44
|
+
* drawables, then the light-map multiplied over it, then the Emissive
|
|
45
|
+
* drawables. With a Post Effect, all of that goes into a target drawn like the
|
|
46
|
+
* canvas and
|
|
47
|
+
* one pass writes it to the canvas.
|
|
48
|
+
*/
|
|
49
|
+
export class FrameComposer {
|
|
50
|
+
surface;
|
|
51
|
+
lightMap = null;
|
|
52
|
+
postPass = null;
|
|
53
|
+
size = new THREE.Vector2();
|
|
54
|
+
constructor(surface) {
|
|
55
|
+
this.surface = surface;
|
|
56
|
+
}
|
|
57
|
+
draw() {
|
|
58
|
+
// Full Ambient Light: the light-map would be 1 everywhere, so nothing to multiply.
|
|
59
|
+
const lit = this.surface.game.lighting.active && !ambientIsFull(this.surface.game.lighting);
|
|
60
|
+
const post = this.surface.game.post.active;
|
|
61
|
+
if (!lit && !post) {
|
|
62
|
+
this.clearLetterbox();
|
|
63
|
+
this.drawScene();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const { width, height } = this.frameSize();
|
|
67
|
+
if (!post) {
|
|
68
|
+
this.clearLetterbox();
|
|
69
|
+
this.drawLit(width, height);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this.drawThroughPost(width, height, lit);
|
|
73
|
+
}
|
|
74
|
+
dispose() {
|
|
75
|
+
this.lightMap?.dispose();
|
|
76
|
+
this.lightMap = null;
|
|
77
|
+
this.postPass?.dispose();
|
|
78
|
+
this.postPass = null;
|
|
79
|
+
}
|
|
80
|
+
/** The light-map's (and the Post Effects') size: the internal resolution, else the drawing buffer (B2). */
|
|
81
|
+
frameSize() {
|
|
82
|
+
const { renderer, resolution } = this.surface;
|
|
83
|
+
if (resolution)
|
|
84
|
+
return { width: Math.max(1, Math.round(resolution.width)), height: Math.max(1, Math.round(resolution.height)) };
|
|
85
|
+
renderer.getDrawingBufferSize(this.size);
|
|
86
|
+
return { width: Math.max(1, this.size.x), height: Math.max(1, this.size.y) };
|
|
87
|
+
}
|
|
88
|
+
/** Letterbox bars: clear the whole canvas, then render inside the scissor. */
|
|
89
|
+
clearLetterbox() {
|
|
90
|
+
const { renderer, resolution } = this.surface;
|
|
91
|
+
if (!resolution)
|
|
92
|
+
return;
|
|
93
|
+
renderer.setScissorTest(false);
|
|
94
|
+
renderer.setClearColor(0x000000, 1);
|
|
95
|
+
renderer.clear(true, false, false);
|
|
96
|
+
renderer.setScissorTest(true);
|
|
97
|
+
}
|
|
98
|
+
/** The scene through its Sprite Batches (ADR 0024), for the camera's current layers. */
|
|
99
|
+
drawScene() {
|
|
100
|
+
const { spriteBatches, renderer } = this.surface;
|
|
101
|
+
const { scene, camera } = this.surface.game;
|
|
102
|
+
spriteBatches.drawFrame(scene, camera, () => renderer.render(scene, camera));
|
|
103
|
+
}
|
|
104
|
+
/** The scene for one layer mask, restoring the camera's own mask after. */
|
|
105
|
+
drawLayers(mask) {
|
|
106
|
+
const layers = this.surface.game.camera.layers;
|
|
107
|
+
const own = layers.mask;
|
|
108
|
+
layers.mask = mask;
|
|
109
|
+
try {
|
|
110
|
+
this.drawScene();
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
layers.mask = own;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Scene, light-map, Emissive drawables, into whatever target is bound now. */
|
|
117
|
+
drawLit(width, height) {
|
|
118
|
+
this.drawLayers(LIT_MASK);
|
|
119
|
+
this.composeLight(width, height);
|
|
120
|
+
}
|
|
121
|
+
composeLight(width, height) {
|
|
122
|
+
const { renderer } = this.surface;
|
|
123
|
+
const { scene, camera, lighting } = this.surface.game;
|
|
124
|
+
this.lightMap ??= new LightMap();
|
|
125
|
+
this.lightMap.render(renderer, camera, {
|
|
126
|
+
width,
|
|
127
|
+
height,
|
|
128
|
+
ambient: ambientMultiplier(lighting),
|
|
129
|
+
lights: lighting.lights,
|
|
130
|
+
projection: this.surface.game.projection,
|
|
131
|
+
occluderRevision: occluderSignature(this.surface.game),
|
|
132
|
+
occluders: () => buildOccluderGrid(occluderSources(this.surface.game.entities)),
|
|
133
|
+
});
|
|
134
|
+
const autoClear = renderer.autoClear;
|
|
135
|
+
const background = scene.background;
|
|
136
|
+
renderer.autoClear = false;
|
|
137
|
+
scene.background = null;
|
|
138
|
+
try {
|
|
139
|
+
this.lightMap.multiplyOver(renderer);
|
|
140
|
+
this.drawLayers(EMISSIVE_MASK);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
renderer.autoClear = autoClear;
|
|
144
|
+
scene.background = background;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** The frame into the Post Effects' canvas-like target, then one pass to the canvas (CA-11). */
|
|
148
|
+
drawThroughPost(width, height, lit) {
|
|
149
|
+
const { renderer } = this.surface;
|
|
150
|
+
const { post } = this.surface.game;
|
|
151
|
+
this.postPass ??= new PostPass();
|
|
152
|
+
this.postPass.resize(width, height);
|
|
153
|
+
// The target stores what the canvas would, so the scene's own (canvas) background clears it.
|
|
154
|
+
renderer.setRenderTarget(this.postPass.target);
|
|
155
|
+
try {
|
|
156
|
+
if (lit)
|
|
157
|
+
this.drawLit(width, height);
|
|
158
|
+
else
|
|
159
|
+
this.drawScene();
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
renderer.setRenderTarget(null);
|
|
163
|
+
}
|
|
164
|
+
this.clearLetterbox();
|
|
165
|
+
this.postPass.draw(renderer, { vignette: post.vignette, colorGrade: post.colorGrade });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { OccluderGrid } from './light-field.js';
|
|
2
|
+
/** What a Tilemap contributes to occlusion: its logical grid and which of its tiles are solid. */
|
|
3
|
+
export interface OccluderSource {
|
|
4
|
+
originX: number;
|
|
5
|
+
originY: number;
|
|
6
|
+
cellSize: number;
|
|
7
|
+
mapWidth: number;
|
|
8
|
+
mapHeight: number;
|
|
9
|
+
cells: readonly number[];
|
|
10
|
+
solidTiles: readonly number[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Every Tilemap's solid tiles rasterized onto one logical grid (inference 8):
|
|
14
|
+
* the finest cell size among them, spanning all their maps. With a single
|
|
15
|
+
* Tilemap — or several sharing a cell size and an aligned origin — each grid
|
|
16
|
+
* cell is exactly one tile. Null when no solid tile is placed anywhere: the
|
|
17
|
+
* lights then cast no shadow at all.
|
|
18
|
+
*/
|
|
19
|
+
export declare function buildOccluderGrid(sources: readonly OccluderSource[]): OccluderGrid | null;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Floating-point slack so a tile edge lying on a grid line covers no neighbouring cell. */
|
|
2
|
+
const EDGE = 1e-6;
|
|
3
|
+
function solidBoxes(source) {
|
|
4
|
+
const solid = new Set(source.solidTiles);
|
|
5
|
+
const width = Math.max(0, Math.floor(source.mapWidth));
|
|
6
|
+
const height = Math.max(0, Math.floor(source.mapHeight));
|
|
7
|
+
const size = source.cellSize;
|
|
8
|
+
const boxes = [];
|
|
9
|
+
if (!(size > 0) || solid.size === 0)
|
|
10
|
+
return boxes;
|
|
11
|
+
for (let index = 0; index < width * height; index += 1) {
|
|
12
|
+
if (!solid.has(source.cells[index] ?? -1))
|
|
13
|
+
continue;
|
|
14
|
+
const minX = source.originX + (index % width) * size;
|
|
15
|
+
const minY = source.originY + Math.floor(index / width) * size;
|
|
16
|
+
boxes.push({ minX, minY, maxX: minX + size, maxY: minY + size });
|
|
17
|
+
}
|
|
18
|
+
return boxes;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Every Tilemap's solid tiles rasterized onto one logical grid (inference 8):
|
|
22
|
+
* the finest cell size among them, spanning all their maps. With a single
|
|
23
|
+
* Tilemap — or several sharing a cell size and an aligned origin — each grid
|
|
24
|
+
* cell is exactly one tile. Null when no solid tile is placed anywhere: the
|
|
25
|
+
* lights then cast no shadow at all.
|
|
26
|
+
*/
|
|
27
|
+
export function buildOccluderGrid(sources) {
|
|
28
|
+
const contributing = sources.map((source) => ({ source, boxes: solidBoxes(source) })).filter(({ boxes }) => boxes.length > 0);
|
|
29
|
+
if (contributing.length === 0)
|
|
30
|
+
return null;
|
|
31
|
+
const cellSize = Math.min(...contributing.map(({ source }) => source.cellSize));
|
|
32
|
+
const originX = Math.min(...contributing.map(({ source }) => source.originX));
|
|
33
|
+
const originY = Math.min(...contributing.map(({ source }) => source.originY));
|
|
34
|
+
const right = Math.max(...contributing.map(({ source }) => source.originX + Math.floor(source.mapWidth) * source.cellSize));
|
|
35
|
+
const top = Math.max(...contributing.map(({ source }) => source.originY + Math.floor(source.mapHeight) * source.cellSize));
|
|
36
|
+
const columns = Math.max(1, Math.ceil((right - originX) / cellSize - EDGE));
|
|
37
|
+
const rows = Math.max(1, Math.ceil((top - originY) / cellSize - EDGE));
|
|
38
|
+
const solid = new Uint8Array(columns * rows);
|
|
39
|
+
for (const { boxes } of contributing) {
|
|
40
|
+
for (const box of boxes) {
|
|
41
|
+
const fromColumn = Math.max(0, Math.floor((box.minX - originX) / cellSize + EDGE));
|
|
42
|
+
const toColumn = Math.min(columns, Math.ceil((box.maxX - originX) / cellSize - EDGE));
|
|
43
|
+
const fromRow = Math.max(0, Math.floor((box.minY - originY) / cellSize + EDGE));
|
|
44
|
+
const toRow = Math.min(rows, Math.ceil((box.maxY - originY) / cellSize - EDGE));
|
|
45
|
+
for (let row = fromRow; row < toRow; row += 1)
|
|
46
|
+
solid.fill(1, row * columns + fromColumn, row * columns + toColumn);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { originX, originY, cellSize, columns, rows, solid };
|
|
50
|
+
}
|
package/dist/particle-batch.js
CHANGED
|
@@ -3,6 +3,7 @@ import { reportRejection } from './report-rejection.js';
|
|
|
3
3
|
import { ParticlePool, particleValueAt, } from './particle-pool.js';
|
|
4
4
|
import { projectIsometric } from './projection.js';
|
|
5
5
|
import { refreshParticleSortBounds } from './particle-sort-bounds.js';
|
|
6
|
+
import { TRANSPARENT_TEXEL_ALPHA } from './render-layers.js';
|
|
6
7
|
function createGeometry(capacity) {
|
|
7
8
|
const geometry = new THREE.BufferGeometry();
|
|
8
9
|
const uvs = new Float32Array(capacity * 8);
|
|
@@ -38,8 +39,8 @@ export class ParticleBatch {
|
|
|
38
39
|
this._layer = Number.isFinite(layer) ? layer : 0;
|
|
39
40
|
this.material = new THREE.MeshBasicMaterial({
|
|
40
41
|
color: 0xffffff,
|
|
41
|
-
|
|
42
|
-
vertexColors: true,
|
|
42
|
+
// alphaTest: see TRANSPARENT_TEXEL_ALPHA (an Emissive behind shows through transparent texels).
|
|
43
|
+
transparent: true, alphaTest: TRANSPARENT_TEXEL_ALPHA, vertexColors: true,
|
|
43
44
|
depthTest: true,
|
|
44
45
|
depthWrite: true,
|
|
45
46
|
blending: blend === 'additive' ? THREE.AdditiveBlending : THREE.NormalBlending,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type ColorGradeEffect, type VignetteEffect } from './scene-render-options.js';
|
|
2
|
+
import type { SceneRenderJson } from './scene.js';
|
|
3
|
+
/**
|
|
4
|
+
* `game.post` (issue #78): the live scene's Post Effects — a vignette and a
|
|
5
|
+
* color grade, each off (null) unless its scene declares it or a game turns
|
|
6
|
+
* it on. With either on, the frame renders through a render target and one
|
|
7
|
+
* full-screen pass; with both off, it draws straight to the canvas. Camera
|
|
8
|
+
* Effects (ADR 0020) sit above and are never altered. They die with their scene.
|
|
9
|
+
*/
|
|
10
|
+
export declare class GamePost {
|
|
11
|
+
private state;
|
|
12
|
+
/** The vignette now, a copy; null when off. */
|
|
13
|
+
get vignette(): VignetteEffect | null;
|
|
14
|
+
/**
|
|
15
|
+
* Turns the vignette off with null, or changes it field by field over the
|
|
16
|
+
* current one (absent fields take their defaults when it was off), clamped to 0..1.
|
|
17
|
+
*/
|
|
18
|
+
set vignette(value: Partial<VignetteEffect> | null);
|
|
19
|
+
/** The color grade now, a copy; null when off. */
|
|
20
|
+
get colorGrade(): ColorGradeEffect | null;
|
|
21
|
+
/**
|
|
22
|
+
* Turns the color grade off with null, or changes it field by field over the
|
|
23
|
+
* current one (absent fields neutral when it was off), clamped — like
|
|
24
|
+
* `game.lighting.ambient`, so a tween of one field keeps the others.
|
|
25
|
+
*/
|
|
26
|
+
set colorGrade(value: Partial<ColorGradeEffect> | null);
|
|
27
|
+
/** Whether any Post Effect is on. */
|
|
28
|
+
get active(): boolean;
|
|
29
|
+
/** Adopts a scene's `render.post`, replacing whatever the previous scene left. Called by Game. */
|
|
30
|
+
loadScene(render: SceneRenderJson | undefined): void;
|
|
31
|
+
/** Every effect off again. */
|
|
32
|
+
unloadScene(): void;
|
|
33
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { resolveColorGrade, resolvePostEffects, resolveVignette, } from './scene-render-options.js';
|
|
2
|
+
/**
|
|
3
|
+
* `game.post` (issue #78): the live scene's Post Effects — a vignette and a
|
|
4
|
+
* color grade, each off (null) unless its scene declares it or a game turns
|
|
5
|
+
* it on. With either on, the frame renders through a render target and one
|
|
6
|
+
* full-screen pass; with both off, it draws straight to the canvas. Camera
|
|
7
|
+
* Effects (ADR 0020) sit above and are never altered. They die with their scene.
|
|
8
|
+
*/
|
|
9
|
+
export class GamePost {
|
|
10
|
+
state = resolvePostEffects(undefined);
|
|
11
|
+
/** The vignette now, a copy; null when off. */
|
|
12
|
+
get vignette() {
|
|
13
|
+
const { vignette } = this.state;
|
|
14
|
+
return vignette ? { ...vignette } : null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Turns the vignette off with null, or changes it field by field over the
|
|
18
|
+
* current one (absent fields take their defaults when it was off), clamped to 0..1.
|
|
19
|
+
*/
|
|
20
|
+
set vignette(value) {
|
|
21
|
+
this.state = { ...this.state, vignette: value ? resolveVignette({ ...this.state.vignette, ...value }) : null };
|
|
22
|
+
}
|
|
23
|
+
/** The color grade now, a copy; null when off. */
|
|
24
|
+
get colorGrade() {
|
|
25
|
+
const { colorGrade } = this.state;
|
|
26
|
+
return colorGrade ? { ...colorGrade } : null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Turns the color grade off with null, or changes it field by field over the
|
|
30
|
+
* current one (absent fields neutral when it was off), clamped — like
|
|
31
|
+
* `game.lighting.ambient`, so a tween of one field keeps the others.
|
|
32
|
+
*/
|
|
33
|
+
set colorGrade(value) {
|
|
34
|
+
this.state = { ...this.state, colorGrade: value ? resolveColorGrade({ ...this.state.colorGrade, ...value }) : null };
|
|
35
|
+
}
|
|
36
|
+
/** Whether any Post Effect is on. */
|
|
37
|
+
get active() {
|
|
38
|
+
return this.state.vignette !== null || this.state.colorGrade !== null;
|
|
39
|
+
}
|
|
40
|
+
/** Adopts a scene's `render.post`, replacing whatever the previous scene left. Called by Game. */
|
|
41
|
+
loadScene(render) {
|
|
42
|
+
this.state = resolvePostEffects(render?.post);
|
|
43
|
+
}
|
|
44
|
+
/** Every effect off again. */
|
|
45
|
+
unloadScene() {
|
|
46
|
+
this.loadScene(undefined);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
import type { PostEffectsState } from './scene-render-options.js';
|
|
3
|
+
/**
|
|
4
|
+
* The Post Effects' render target and pass: the frame is drawn into `target`
|
|
5
|
+
* exactly as onto the canvas — 8-bit, sRGB-encoded, blending on sRGB values
|
|
6
|
+
* (ADR 0025's canvas path) — at the light-map resolution with nearest upscale
|
|
7
|
+
* (E2), then `draw` writes it to the canvas through the effects. A half-float
|
|
8
|
+
* linear target drew nothing on CI's headless Linux WebGPU (PR #150), and it
|
|
9
|
+
* changed every translucent edge.
|
|
10
|
+
*/
|
|
11
|
+
export declare class PostPass {
|
|
12
|
+
readonly target: THREE.RenderTarget<THREE.Texture<unknown, THREE.TextureEventMap>, THREE.RenderTargetEventMap>;
|
|
13
|
+
private readonly uniforms;
|
|
14
|
+
private readonly drawnLikeCanvas;
|
|
15
|
+
private readonly quad;
|
|
16
|
+
/** Sizes the target for this frame. */
|
|
17
|
+
resize(width: number, height: number): void;
|
|
18
|
+
/** Draws the target to whatever is bound now (the canvas), with this frame's effects. */
|
|
19
|
+
draw(renderer: THREE.WebGPURenderer, effects: PostEffectsState): void;
|
|
20
|
+
private setVignette;
|
|
21
|
+
private setColorGrade;
|
|
22
|
+
dispose(): void;
|
|
23
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { clamp, dot, float, length, max, mix, texture, uniform, uv, vec3, vec4 } from 'three/tsl';
|
|
2
|
+
import * as THREE from 'three/webgpu';
|
|
3
|
+
import { FrameQuad } from './frame-quad.js';
|
|
4
|
+
import { hexChannels } from './scene-lighting.js';
|
|
5
|
+
import { drawLikeCanvas } from './render-output.js';
|
|
6
|
+
function createUniforms() {
|
|
7
|
+
return {
|
|
8
|
+
vignetteOn: uniform(0),
|
|
9
|
+
vignetteIntensity: uniform(0),
|
|
10
|
+
vignetteRadius: uniform(0),
|
|
11
|
+
gradeOn: uniform(0),
|
|
12
|
+
tint: uniform(new THREE.Vector3(1, 1, 1)),
|
|
13
|
+
contrast: uniform(1),
|
|
14
|
+
saturation: uniform(1),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The single full-screen pass of the Post Effects (issue #78 CA-11): it reads
|
|
19
|
+
* the frame from the target, already in the canvas's sRGB values, grades it —
|
|
20
|
+
* tint, then saturation around Rec. 709 luma, then contrast around mid grey —
|
|
21
|
+
* and darkens it towards the corners. Its output is the canvas's sRGB value.
|
|
22
|
+
*/
|
|
23
|
+
function createPostMaterial(scene, u) {
|
|
24
|
+
const material = new THREE.MeshBasicNodeMaterial();
|
|
25
|
+
// Already the canvas's sRGB values (drawLikeCanvas): graded as they would show.
|
|
26
|
+
const display = texture(scene, uv()).rgb;
|
|
27
|
+
const tinted = display.mul(u.tint);
|
|
28
|
+
const luma = dot(tinted, vec3(0.2126, 0.7152, 0.0722));
|
|
29
|
+
const saturated = mix(vec3(luma, luma, luma), tinted, u.saturation);
|
|
30
|
+
const graded = saturated.sub(0.5).mul(u.contrast).add(0.5);
|
|
31
|
+
const color = mix(display, graded, u.gradeOn);
|
|
32
|
+
// 0 at the centre, 1 at the corners.
|
|
33
|
+
const distance = length(uv().sub(0.5).mul(2)).div(Math.SQRT2);
|
|
34
|
+
const ramp = clamp(distance.sub(u.vignetteRadius).div(max(float(1).sub(u.vignetteRadius), 1e-3)), 0, 1);
|
|
35
|
+
const eased = ramp.mul(ramp).mul(float(3).sub(ramp.mul(2)));
|
|
36
|
+
const vignette = float(1).sub(u.vignetteIntensity.mul(eased).mul(u.vignetteOn));
|
|
37
|
+
material.fragmentNode = vec4(clamp(color.mul(vignette), vec3(0, 0, 0), vec3(1, 1, 1)), 1);
|
|
38
|
+
material.depthTest = false;
|
|
39
|
+
material.depthWrite = false;
|
|
40
|
+
return material;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The Post Effects' render target and pass: the frame is drawn into `target`
|
|
44
|
+
* exactly as onto the canvas — 8-bit, sRGB-encoded, blending on sRGB values
|
|
45
|
+
* (ADR 0025's canvas path) — at the light-map resolution with nearest upscale
|
|
46
|
+
* (E2), then `draw` writes it to the canvas through the effects. A half-float
|
|
47
|
+
* linear target drew nothing on CI's headless Linux WebGPU (PR #150), and it
|
|
48
|
+
* changed every translucent edge.
|
|
49
|
+
*/
|
|
50
|
+
export class PostPass {
|
|
51
|
+
target = new THREE.RenderTarget(1, 1, {
|
|
52
|
+
type: THREE.UnsignedByteType,
|
|
53
|
+
minFilter: THREE.NearestFilter,
|
|
54
|
+
magFilter: THREE.NearestFilter,
|
|
55
|
+
depthBuffer: true,
|
|
56
|
+
// The canvas's own MSAA (antialias: true): geometry edges match the frame without post.
|
|
57
|
+
samples: 4,
|
|
58
|
+
});
|
|
59
|
+
uniforms = createUniforms();
|
|
60
|
+
drawnLikeCanvas = drawLikeCanvas(this.target);
|
|
61
|
+
quad = new FrameQuad(createPostMaterial(this.target.texture, this.uniforms));
|
|
62
|
+
/** Sizes the target for this frame. */
|
|
63
|
+
resize(width, height) {
|
|
64
|
+
if (this.target.width !== width || this.target.height !== height)
|
|
65
|
+
this.target.setSize(width, height);
|
|
66
|
+
}
|
|
67
|
+
/** Draws the target to whatever is bound now (the canvas), with this frame's effects. */
|
|
68
|
+
draw(renderer, effects) {
|
|
69
|
+
this.setVignette(effects.vignette);
|
|
70
|
+
this.setColorGrade(effects.colorGrade);
|
|
71
|
+
this.quad.render(renderer);
|
|
72
|
+
}
|
|
73
|
+
setVignette(vignette) {
|
|
74
|
+
const u = this.uniforms;
|
|
75
|
+
u.vignetteOn.value = vignette ? 1 : 0;
|
|
76
|
+
u.vignetteIntensity.value = vignette ? vignette.intensity : 0;
|
|
77
|
+
u.vignetteRadius.value = vignette ? vignette.radius : 0;
|
|
78
|
+
}
|
|
79
|
+
setColorGrade(grade) {
|
|
80
|
+
const u = this.uniforms;
|
|
81
|
+
const neutral = { tint: '#ffffff', contrast: 1, saturation: 1 };
|
|
82
|
+
const { tint, contrast, saturation } = grade ?? neutral;
|
|
83
|
+
u.gradeOn.value = grade ? 1 : 0;
|
|
84
|
+
const [r, g, b] = hexChannels(tint);
|
|
85
|
+
u.tint.value.set(r, g, b);
|
|
86
|
+
u.contrast.value = contrast;
|
|
87
|
+
u.saturation.value = saturation;
|
|
88
|
+
}
|
|
89
|
+
dispose() {
|
|
90
|
+
this.target.dispose();
|
|
91
|
+
this.quad.dispose();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type * as THREE from 'three/webgpu';
|
|
2
|
+
/**
|
|
3
|
+
* The three.js layer an Emissive drawable lives on, alone (issue #78 CA-10).
|
|
4
|
+
* The Game's camera sees layer 0 and this one, so an unlit scene draws an
|
|
5
|
+
* Emissive drawable in place like any other; a lit scene draws layer 0, then
|
|
6
|
+
* its light-map, then this layer at full brightness (ADR 0026).
|
|
7
|
+
*/
|
|
8
|
+
export declare const EMISSIVE_LAYER = 1;
|
|
9
|
+
/** Puts `object` on the Emissive layer alone, or back on the default layer 0. */
|
|
10
|
+
export declare function setEmissive(object: THREE.Object3D, emissive: boolean): void;
|
|
11
|
+
/**
|
|
12
|
+
* The alphaTest every drawable carries (PR #150 review): a texel whose alpha
|
|
13
|
+
* byte is 0 is discarded instead of drawn. It contributes no color either way
|
|
14
|
+
* (normal blending keeps the frame), but discarded it also writes no depth, so
|
|
15
|
+
* an Emissive drawable drawn after the lit pass shows through the transparent
|
|
16
|
+
* texels of a lit sprite in front of it, and stays hidden behind its opaque ones.
|
|
17
|
+
*/
|
|
18
|
+
export declare const TRANSPARENT_TEXEL_ALPHA: number;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three.js layer an Emissive drawable lives on, alone (issue #78 CA-10).
|
|
3
|
+
* The Game's camera sees layer 0 and this one, so an unlit scene draws an
|
|
4
|
+
* Emissive drawable in place like any other; a lit scene draws layer 0, then
|
|
5
|
+
* its light-map, then this layer at full brightness (ADR 0026).
|
|
6
|
+
*/
|
|
7
|
+
export const EMISSIVE_LAYER = 1;
|
|
8
|
+
/** Puts `object` on the Emissive layer alone, or back on the default layer 0. */
|
|
9
|
+
export function setEmissive(object, emissive) {
|
|
10
|
+
object.layers.set(emissive ? EMISSIVE_LAYER : 0);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The alphaTest every drawable carries (PR #150 review): a texel whose alpha
|
|
14
|
+
* byte is 0 is discarded instead of drawn. It contributes no color either way
|
|
15
|
+
* (normal blending keeps the frame), but discarded it also writes no depth, so
|
|
16
|
+
* an Emissive drawable drawn after the lit pass shows through the transparent
|
|
17
|
+
* texels of a lit sprite in front of it, and stays hidden behind its opaque ones.
|
|
18
|
+
*/
|
|
19
|
+
export const TRANSPARENT_TEXEL_ALPHA = 1 / 512;
|
package/dist/render-output.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ import * as THREE from 'three/webgpu';
|
|
|
8
8
|
* one more draw per frame. A render target keeps the linear working space.
|
|
9
9
|
*/
|
|
10
10
|
export declare function drawStraightToCanvas(renderer: THREE.WebGPURenderer): void;
|
|
11
|
+
/**
|
|
12
|
+
* Makes `target` receive what the canvas would (issue #78): every material
|
|
13
|
+
* encodes to sRGB into it and blends on sRGB values, so a frame drawn there
|
|
14
|
+
* and copied to the canvas is the frame drawn straight to the canvas. The
|
|
15
|
+
* Post Effects' 8-bit target.
|
|
16
|
+
*/
|
|
17
|
+
export declare function drawLikeCanvas(target: THREE.RenderTarget): THREE.RenderTarget;
|
|
11
18
|
/**
|
|
12
19
|
* The scene background for a canvas drawn straight: three clears with a
|
|
13
20
|
* color's working-space (linear) components, and the canvas stores sRGB, so
|
package/dist/render-output.js
CHANGED
|
@@ -12,7 +12,23 @@ export function drawStraightToCanvas(renderer) {
|
|
|
12
12
|
// The working space as output: three then needs no intermediate target.
|
|
13
13
|
renderer.outputColorSpace = THREE.ColorManagement.workingColorSpace;
|
|
14
14
|
const context = renderer.contextNode.value;
|
|
15
|
-
context.getOutput = (output, builder) =>
|
|
15
|
+
context.getOutput = (output, builder) => {
|
|
16
|
+
const target = builder.renderer.getRenderTarget();
|
|
17
|
+
const likeCanvas = target === null || (typeof target === 'object' && canvasLike.has(target));
|
|
18
|
+
return likeCanvas ? workingToColorSpace(output, THREE.SRGBColorSpace) : output;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Render targets that hold a frame exactly as the canvas would: sRGB-encoded 8-bit values. */
|
|
22
|
+
const canvasLike = new WeakSet();
|
|
23
|
+
/**
|
|
24
|
+
* Makes `target` receive what the canvas would (issue #78): every material
|
|
25
|
+
* encodes to sRGB into it and blends on sRGB values, so a frame drawn there
|
|
26
|
+
* and copied to the canvas is the frame drawn straight to the canvas. The
|
|
27
|
+
* Post Effects' 8-bit target.
|
|
28
|
+
*/
|
|
29
|
+
export function drawLikeCanvas(target) {
|
|
30
|
+
canvasLike.add(target);
|
|
31
|
+
return target;
|
|
16
32
|
}
|
|
17
33
|
/**
|
|
18
34
|
* The scene background for a canvas drawn straight: three clears with a
|
|
@@ -3,6 +3,7 @@ import type { CameraEffectsState } from './camera-effects.js';
|
|
|
3
3
|
import type { Game } from './game.js';
|
|
4
4
|
import type { RuntimeMetadata } from './runtime-bridge.js';
|
|
5
5
|
import type { StatValue } from './stats.js';
|
|
6
|
+
import { type RuntimeSnapshotLighting, type RuntimeSnapshotPost } from './runtime-lighting-snapshot.js';
|
|
6
7
|
export type ProjectionMarkerKind = 'cycle' | 'unsupported' | 'error' | 'truncated';
|
|
7
8
|
export interface ProjectionMarker {
|
|
8
9
|
$waica: ProjectionMarkerKind | 'date' | 'bigint' | 'map' | 'set';
|
|
@@ -124,6 +125,10 @@ export interface RuntimeSnapshot extends RuntimeMetadata {
|
|
|
124
125
|
time: RuntimeSnapshotTime;
|
|
125
126
|
ui: RuntimeSnapshotUi;
|
|
126
127
|
camera: RuntimeSnapshotCamera;
|
|
128
|
+
/** Ambient Light and live Lights (issue #78 CA-12), logical coordinates; never filtered. */
|
|
129
|
+
lighting: RuntimeSnapshotLighting;
|
|
130
|
+
/** Post Effects (issue #78 CA-12), each null when off; never filtered. */
|
|
131
|
+
post: RuntimeSnapshotPost;
|
|
127
132
|
}
|
|
128
133
|
export declare const RUNTIME_PROJECTION_LIMITS: {
|
|
129
134
|
readonly depth: 5;
|
|
@@ -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);
|