@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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { abs, Break, ceil, clamp, float, floor, Fn, If, int, ivec2, length, Loop, max, min, mix, positionWorld, select, sign, texture, uniform, uv, vec2, vec4, } from 'three/tsl';
|
|
2
|
+
import * as THREE from 'three/webgpu';
|
|
3
|
+
const draws = new WeakMap();
|
|
4
|
+
/** Registers what a light-map mesh draws; the shared material reads it per object. */
|
|
5
|
+
export function setLightDraw(mesh, draw) {
|
|
6
|
+
draws.set(mesh, draw);
|
|
7
|
+
}
|
|
8
|
+
function drawOf(object) {
|
|
9
|
+
return object ? draws.get(object) : undefined;
|
|
10
|
+
}
|
|
11
|
+
/** The light-map's shared uniforms, with an empty occluder grid. */
|
|
12
|
+
export function createLightMapUniforms(empty) {
|
|
13
|
+
return {
|
|
14
|
+
isometric: uniform(0),
|
|
15
|
+
grid: uniform(new THREE.Vector4(0, 0, 1, 0)),
|
|
16
|
+
gridSize: uniform(new THREE.Vector2(1, 1)),
|
|
17
|
+
// An explicit UV: mapUvPerVertex (render-output.ts) leaves every other
|
|
18
|
+
// texture a per-vertex UV varying, one per load() here, past WebGPU's
|
|
19
|
+
// limit of 16 inter-stage variables. The grid is only ever load()ed.
|
|
20
|
+
occluders: texture(empty, vec2(0, 0)),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** 1 when the grid cell holds a solid tile, 0 when it is open or outside the grid. */
|
|
24
|
+
function solidAtNode(uniforms, cell) {
|
|
25
|
+
const size = uniforms.gridSize;
|
|
26
|
+
const inside = cell.x.greaterThanEqual(0).and(cell.y.greaterThanEqual(0))
|
|
27
|
+
.and(cell.x.lessThan(size.x)).and(cell.y.lessThan(size.y));
|
|
28
|
+
const clamped = clamp(cell, vec2(0, 0), size.sub(1));
|
|
29
|
+
// load() follows the uniform's texture, so a rebuilt grid reaches the shader.
|
|
30
|
+
const value = uniforms.occluders.load(ivec2(int(clamped.x), int(clamped.y))).r;
|
|
31
|
+
return select(inside, value, float(0));
|
|
32
|
+
}
|
|
33
|
+
/** light-field.ts `axisCrossing`: the first boundary's ray parameter and its growth per cell. */
|
|
34
|
+
function axisCrossingNode(start, delta, cell) {
|
|
35
|
+
const flat = abs(delta).lessThan(1e-9);
|
|
36
|
+
const step = select(flat, float(1e30), float(1).div(max(abs(delta), 1e-9)));
|
|
37
|
+
const toBoundary = select(delta.greaterThan(0), cell.add(1).sub(start), start.sub(cell));
|
|
38
|
+
return { next: select(flat, float(1e30), toBoundary.mul(step)), step };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* `segmentClear` of light-field.ts in TSL: walks the grid cells from the
|
|
42
|
+
* texel's to the light's (Amanatides–Woo) and returns 0 at the first solid
|
|
43
|
+
* one, never testing the texel's own cell — the lit face of a wall.
|
|
44
|
+
*/
|
|
45
|
+
function segmentClearNode(uniforms) {
|
|
46
|
+
const walk = Fn(([from, to]) => {
|
|
47
|
+
const grid = uniforms.grid;
|
|
48
|
+
const a = from.sub(grid.xy).div(grid.z).toVar();
|
|
49
|
+
const b = to.sub(grid.xy).div(grid.z).toVar();
|
|
50
|
+
const cell = floor(a).toVar();
|
|
51
|
+
const end = floor(b);
|
|
52
|
+
const delta = b.sub(a).toVar();
|
|
53
|
+
const x = axisCrossingNode(a.x, delta.x, cell.x);
|
|
54
|
+
const y = axisCrossingNode(a.y, delta.y, cell.y);
|
|
55
|
+
const nextX = x.next.toVar();
|
|
56
|
+
const nextY = y.next.toVar();
|
|
57
|
+
const cells = min(abs(end.x.sub(cell.x)).add(abs(end.y.sub(cell.y))), 4096);
|
|
58
|
+
const clear = float(1).toVar();
|
|
59
|
+
Loop({ start: int(0), end: int(cells), type: 'int', condition: '<' }, () => {
|
|
60
|
+
If(nextX.lessThan(nextY), () => {
|
|
61
|
+
cell.x.addAssign(sign(delta.x));
|
|
62
|
+
nextX.addAssign(x.step);
|
|
63
|
+
}).Else(() => {
|
|
64
|
+
cell.y.addAssign(sign(delta.y));
|
|
65
|
+
nextY.addAssign(y.step);
|
|
66
|
+
});
|
|
67
|
+
If(solidAtNode(uniforms, cell).greaterThan(0.5), () => {
|
|
68
|
+
clear.assign(0);
|
|
69
|
+
Break();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
return clear;
|
|
73
|
+
});
|
|
74
|
+
return (from, to) => walk(from, to);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* `segmentPenetration` of light-field.ts in TSL: the same walk, summing in
|
|
78
|
+
* logical units how far the ray runs through solid cells, the texel's own
|
|
79
|
+
* cell never counted.
|
|
80
|
+
*/
|
|
81
|
+
function segmentPenetrationNode(uniforms) {
|
|
82
|
+
const walk = Fn(([from, to]) => {
|
|
83
|
+
const grid = uniforms.grid;
|
|
84
|
+
const a = from.sub(grid.xy).div(grid.z).toVar();
|
|
85
|
+
const b = to.sub(grid.xy).div(grid.z).toVar();
|
|
86
|
+
const cell = floor(a).toVar();
|
|
87
|
+
const end = floor(b);
|
|
88
|
+
const delta = b.sub(a).toVar();
|
|
89
|
+
const x = axisCrossingNode(a.x, delta.x, cell.x);
|
|
90
|
+
const y = axisCrossingNode(a.y, delta.y, cell.y);
|
|
91
|
+
const nextX = x.next.toVar();
|
|
92
|
+
const nextY = y.next.toVar();
|
|
93
|
+
const entry = float(0).toVar();
|
|
94
|
+
const cells = min(abs(end.x.sub(cell.x)).add(abs(end.y.sub(cell.y))), 4096);
|
|
95
|
+
const length = to.sub(from).length();
|
|
96
|
+
const inside = float(0).toVar();
|
|
97
|
+
Loop({ start: int(0), end: int(cells), type: 'int', condition: '<' }, () => {
|
|
98
|
+
If(nextX.lessThan(nextY), () => {
|
|
99
|
+
entry.assign(nextX);
|
|
100
|
+
cell.x.addAssign(sign(delta.x));
|
|
101
|
+
nextX.addAssign(x.step);
|
|
102
|
+
}).Else(() => {
|
|
103
|
+
entry.assign(nextY);
|
|
104
|
+
cell.y.addAssign(sign(delta.y));
|
|
105
|
+
nextY.addAssign(y.step);
|
|
106
|
+
});
|
|
107
|
+
const exit = min(min(nextX, nextY), 1);
|
|
108
|
+
inside.addAssign(solidAtNode(uniforms, cell).mul(exit.sub(entry)).mul(length));
|
|
109
|
+
});
|
|
110
|
+
return inside;
|
|
111
|
+
});
|
|
112
|
+
return (from, to) => walk(from, to);
|
|
113
|
+
}
|
|
114
|
+
/** One light's per-object uniforms, read from the mesh being drawn. */
|
|
115
|
+
function lightUniforms() {
|
|
116
|
+
return {
|
|
117
|
+
position: uniform(new THREE.Vector2()).onObjectUpdate(({ object }) => drawOf(object)?.position),
|
|
118
|
+
color: uniform(new THREE.Vector3()).onObjectUpdate(({ object }) => drawOf(object)?.color),
|
|
119
|
+
radius: uniform(1).onObjectUpdate(({ object }) => drawOf(object)?.radius),
|
|
120
|
+
bands: uniform(0).onObjectUpdate(({ object }) => drawOf(object)?.bands),
|
|
121
|
+
softness: uniform(0).onObjectUpdate(({ object }) => drawOf(object)?.softness),
|
|
122
|
+
castShadows: uniform(1).onObjectUpdate(({ object }) => drawOf(object)?.castShadows),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** The texel's logical position: render space as is, or unprojected from the 2:1 lattice (ADR 0009). */
|
|
126
|
+
function logicalPositionNode(isometric) {
|
|
127
|
+
const world = positionWorld.xy;
|
|
128
|
+
const unprojected = vec2(world.x.div(2).sub(world.y), world.x.div(2).negate().sub(world.y));
|
|
129
|
+
return mix(world, unprojected, isometric);
|
|
130
|
+
}
|
|
131
|
+
/** light-field.ts `lightFalloff` at `t` = d / radius: smooth, or the top of its band. */
|
|
132
|
+
function falloffNode(t, bands) {
|
|
133
|
+
const c = clamp(t, 0, 1);
|
|
134
|
+
const smooth = float(1).sub(c.mul(c).mul(float(3).sub(c.mul(2))));
|
|
135
|
+
const banded = ceil(smooth.mul(bands)).div(max(bands, 1));
|
|
136
|
+
return select(t.lessThan(1), select(bands.greaterThan(0.5), banded, smooth), float(0));
|
|
137
|
+
}
|
|
138
|
+
/** light-field.ts `lightVisibility`: hard, the clear walk; soft, a smoothstep over how deep the ray runs through walls. */
|
|
139
|
+
function visibilityNode(uniforms, light, logical) {
|
|
140
|
+
const visibility = float(1).toVar();
|
|
141
|
+
If(light.castShadows.greaterThan(0.5).and(uniforms.grid.w.greaterThan(0.5)), () => {
|
|
142
|
+
If(light.softness.greaterThan(0), () => {
|
|
143
|
+
const penetration = segmentPenetrationNode(uniforms)(logical, light.position);
|
|
144
|
+
const depth = clamp(penetration.div(light.softness.mul(uniforms.grid.z)), 0, 1);
|
|
145
|
+
visibility.assign(float(1).sub(depth.mul(depth).mul(float(3).sub(depth.mul(2)))));
|
|
146
|
+
}).Else(() => {
|
|
147
|
+
visibility.assign(segmentClearNode(uniforms)(logical, light.position));
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
return visibility;
|
|
151
|
+
}
|
|
152
|
+
/** Adds the light onto the cleared target: ONE + ONE, which the 8-bit target clamps at 1. */
|
|
153
|
+
function addOnto(material) {
|
|
154
|
+
material.transparent = true;
|
|
155
|
+
material.depthTest = false;
|
|
156
|
+
material.depthWrite = false;
|
|
157
|
+
material.blending = THREE.CustomBlending;
|
|
158
|
+
material.blendEquation = THREE.AddEquation;
|
|
159
|
+
material.blendSrc = THREE.OneFactor;
|
|
160
|
+
material.blendDst = THREE.OneFactor;
|
|
161
|
+
material.blendSrcAlpha = THREE.OneFactor;
|
|
162
|
+
material.blendDstAlpha = THREE.OneFactor;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The material every light-map mesh shares (issue #78 CA-5..CA-7): per texel,
|
|
166
|
+
* `color × intensity × falloff(d / radius) × visibility` with `d` measured in
|
|
167
|
+
* logical space (ADR 0009), added onto the Ambient Light the target was
|
|
168
|
+
* cleared to. light-field.ts is its specification.
|
|
169
|
+
*/
|
|
170
|
+
export function createLightMaterial(uniforms) {
|
|
171
|
+
const light = lightUniforms();
|
|
172
|
+
const material = new THREE.MeshBasicNodeMaterial();
|
|
173
|
+
material.fragmentNode = Fn(() => {
|
|
174
|
+
const logical = logicalPositionNode(uniforms.isometric).toVar();
|
|
175
|
+
const reach = falloffNode(length(logical.sub(light.position)).div(max(light.radius, 1e-6)), light.bands).toVar();
|
|
176
|
+
const visibility = float(0).toVar();
|
|
177
|
+
If(reach.greaterThan(0), () => {
|
|
178
|
+
visibility.assign(visibilityNode(uniforms, light, logical));
|
|
179
|
+
});
|
|
180
|
+
return vec4(light.color.mul(reach).mul(visibility), 1);
|
|
181
|
+
})();
|
|
182
|
+
addOnto(material);
|
|
183
|
+
return material;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The quad that multiplies the light-map over the frame already drawn
|
|
187
|
+
* (ADR 0026): `frame × light`, alpha kept, on the sRGB values as stored —
|
|
188
|
+
* on the canvas, or in a Post Effect's target, which stores them alike.
|
|
189
|
+
*/
|
|
190
|
+
export function createMultiplyMaterial(light) {
|
|
191
|
+
const material = new THREE.MeshBasicNodeMaterial();
|
|
192
|
+
material.fragmentNode = vec4(texture(light, uv()).rgb, 1);
|
|
193
|
+
material.transparent = true;
|
|
194
|
+
material.depthTest = false;
|
|
195
|
+
material.depthWrite = false;
|
|
196
|
+
material.blending = THREE.CustomBlending;
|
|
197
|
+
material.blendEquation = THREE.AddEquation;
|
|
198
|
+
material.blendSrc = THREE.ZeroFactor;
|
|
199
|
+
material.blendDst = THREE.SrcColorFactor;
|
|
200
|
+
material.blendSrcAlpha = THREE.ZeroFactor;
|
|
201
|
+
material.blendDstAlpha = THREE.OneFactor;
|
|
202
|
+
return material;
|
|
203
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
import type { Light } from './components/light.js';
|
|
3
|
+
import { type OccluderGrid, type Rgb } from './light-field.js';
|
|
4
|
+
/** What one frame's light-map is drawn from. */
|
|
5
|
+
export interface LightMapFrame {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
/** The Ambient Light the target is cleared to, as multipliers. */
|
|
9
|
+
ambient: Rgb;
|
|
10
|
+
lights: readonly Light[];
|
|
11
|
+
projection: 'isometric' | null;
|
|
12
|
+
/** Changes whenever the solid tiles or a Tilemap's origin change; the grid is rebuilt only then. */
|
|
13
|
+
occluderRevision: string;
|
|
14
|
+
occluders: () => OccluderGrid | null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The scene's light-map (issue #78, ADR 0026): an 8-bit target (inference
|
|
18
|
+
* 16) the size of the internal resolution — or of the drawing buffer — with
|
|
19
|
+
* nearest upscale, cleared to the Ambient Light, onto which each live Light
|
|
20
|
+
* adds its term through one mesh over its footprint. Then multiplied over
|
|
21
|
+
* the frame by one quad.
|
|
22
|
+
*/
|
|
23
|
+
export declare class LightMap {
|
|
24
|
+
readonly target: THREE.RenderTarget<THREE.Texture<unknown, THREE.TextureEventMap>, THREE.RenderTargetEventMap>;
|
|
25
|
+
private readonly scene;
|
|
26
|
+
private readonly geometry;
|
|
27
|
+
private readonly empty;
|
|
28
|
+
private readonly uniforms;
|
|
29
|
+
private readonly material;
|
|
30
|
+
private readonly meshes;
|
|
31
|
+
private readonly multiply;
|
|
32
|
+
private occluderTexture;
|
|
33
|
+
private builtRevision;
|
|
34
|
+
private readonly clear;
|
|
35
|
+
private readonly savedClear;
|
|
36
|
+
/** Draws this frame's light-map into its target and leaves the renderer bound to `previous`. */
|
|
37
|
+
render(renderer: THREE.WebGPURenderer, camera: THREE.Camera, frame: LightMapFrame): void;
|
|
38
|
+
/** Multiplies the light-map over what is bound now: the canvas, or a Post Effect's canvas-like target. */
|
|
39
|
+
multiplyOver(renderer: THREE.WebGPURenderer): void;
|
|
40
|
+
dispose(): void;
|
|
41
|
+
private syncOccluders;
|
|
42
|
+
/** One mesh per live Light, placed over its footprint; a destroyed Light's mesh leaves now. */
|
|
43
|
+
private syncLights;
|
|
44
|
+
private place;
|
|
45
|
+
private createMesh;
|
|
46
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
import { FrameQuad } from './frame-quad.js';
|
|
3
|
+
import { lightFootprint } from './light-field.js';
|
|
4
|
+
import { createLightMapUniforms, createLightMaterial, createMultiplyMaterial, setLightDraw, } from './light-map-material.js';
|
|
5
|
+
import { projectIsometric } from './projection.js';
|
|
6
|
+
function emptyOccluders() {
|
|
7
|
+
const texture = new THREE.DataTexture(new Uint8Array([0]), 1, 1, THREE.RedFormat, THREE.UnsignedByteType);
|
|
8
|
+
texture.needsUpdate = true;
|
|
9
|
+
return texture;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The scene's light-map (issue #78, ADR 0026): an 8-bit target (inference
|
|
13
|
+
* 16) the size of the internal resolution — or of the drawing buffer — with
|
|
14
|
+
* nearest upscale, cleared to the Ambient Light, onto which each live Light
|
|
15
|
+
* adds its term through one mesh over its footprint. Then multiplied over
|
|
16
|
+
* the frame by one quad.
|
|
17
|
+
*/
|
|
18
|
+
export class LightMap {
|
|
19
|
+
target = new THREE.RenderTarget(1, 1, {
|
|
20
|
+
type: THREE.UnsignedByteType,
|
|
21
|
+
minFilter: THREE.NearestFilter,
|
|
22
|
+
magFilter: THREE.NearestFilter,
|
|
23
|
+
depthBuffer: false,
|
|
24
|
+
});
|
|
25
|
+
scene = new THREE.Scene();
|
|
26
|
+
geometry = new THREE.PlaneGeometry(1, 1);
|
|
27
|
+
empty = emptyOccluders();
|
|
28
|
+
uniforms = createLightMapUniforms(this.empty);
|
|
29
|
+
material = createLightMaterial(this.uniforms);
|
|
30
|
+
meshes = new Map();
|
|
31
|
+
multiply = new FrameQuad(createMultiplyMaterial(this.target.texture));
|
|
32
|
+
occluderTexture = null;
|
|
33
|
+
builtRevision = '';
|
|
34
|
+
clear = new THREE.Color();
|
|
35
|
+
savedClear = new THREE.Color();
|
|
36
|
+
/** Draws this frame's light-map into its target and leaves the renderer bound to `previous`. */
|
|
37
|
+
render(renderer, camera, frame) {
|
|
38
|
+
if (this.target.width !== frame.width || this.target.height !== frame.height) {
|
|
39
|
+
this.target.setSize(frame.width, frame.height);
|
|
40
|
+
}
|
|
41
|
+
this.syncOccluders(frame);
|
|
42
|
+
this.syncLights(frame);
|
|
43
|
+
const previous = renderer.getRenderTarget();
|
|
44
|
+
const alpha = renderer.getClearAlpha();
|
|
45
|
+
renderer.getClearColor(this.savedClear);
|
|
46
|
+
this.clear.setRGB(frame.ambient[0], frame.ambient[1], frame.ambient[2], THREE.LinearSRGBColorSpace);
|
|
47
|
+
renderer.setClearColor(this.clear, 1);
|
|
48
|
+
renderer.setRenderTarget(this.target);
|
|
49
|
+
renderer.render(this.scene, camera);
|
|
50
|
+
renderer.setRenderTarget(previous);
|
|
51
|
+
renderer.setClearColor(this.savedClear, alpha);
|
|
52
|
+
}
|
|
53
|
+
/** Multiplies the light-map over what is bound now: the canvas, or a Post Effect's canvas-like target. */
|
|
54
|
+
multiplyOver(renderer) {
|
|
55
|
+
this.multiply.render(renderer);
|
|
56
|
+
}
|
|
57
|
+
dispose() {
|
|
58
|
+
this.target.dispose();
|
|
59
|
+
this.geometry.dispose();
|
|
60
|
+
this.material.dispose();
|
|
61
|
+
this.multiply.dispose();
|
|
62
|
+
this.empty.dispose();
|
|
63
|
+
this.occluderTexture?.dispose();
|
|
64
|
+
this.meshes.clear();
|
|
65
|
+
}
|
|
66
|
+
syncOccluders(frame) {
|
|
67
|
+
if (frame.occluderRevision === this.builtRevision)
|
|
68
|
+
return;
|
|
69
|
+
this.builtRevision = frame.occluderRevision;
|
|
70
|
+
this.occluderTexture?.dispose();
|
|
71
|
+
this.occluderTexture = null;
|
|
72
|
+
const grid = frame.occluders();
|
|
73
|
+
if (!grid) {
|
|
74
|
+
this.uniforms.occluders.value = this.empty;
|
|
75
|
+
this.uniforms.grid.value.set(0, 0, 1, 0);
|
|
76
|
+
this.uniforms.gridSize.value.set(1, 1);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const bytes = grid.solid.map((value) => (value === 1 ? 255 : 0));
|
|
80
|
+
const texture = new THREE.DataTexture(bytes, grid.columns, grid.rows, THREE.RedFormat, THREE.UnsignedByteType);
|
|
81
|
+
texture.needsUpdate = true;
|
|
82
|
+
this.occluderTexture = texture;
|
|
83
|
+
this.uniforms.occluders.value = texture;
|
|
84
|
+
this.uniforms.grid.value.set(grid.originX, grid.originY, grid.cellSize, 1);
|
|
85
|
+
this.uniforms.gridSize.value.set(grid.columns, grid.rows);
|
|
86
|
+
}
|
|
87
|
+
/** One mesh per live Light, placed over its footprint; a destroyed Light's mesh leaves now. */
|
|
88
|
+
syncLights(frame) {
|
|
89
|
+
this.uniforms.isometric.value = frame.projection === 'isometric' ? 1 : 0;
|
|
90
|
+
const live = new Set(frame.lights);
|
|
91
|
+
for (const [light, entry] of this.meshes) {
|
|
92
|
+
if (live.has(light))
|
|
93
|
+
continue;
|
|
94
|
+
entry.mesh.removeFromParent();
|
|
95
|
+
this.meshes.delete(light);
|
|
96
|
+
}
|
|
97
|
+
for (const light of frame.lights)
|
|
98
|
+
this.place(light, frame.projection);
|
|
99
|
+
}
|
|
100
|
+
place(light, projection) {
|
|
101
|
+
const entry = this.meshes.get(light) ?? this.createMesh(light);
|
|
102
|
+
const field = light.field();
|
|
103
|
+
entry.draw.position.set(field.x, field.y);
|
|
104
|
+
entry.draw.color.set(field.color[0], field.color[1], field.color[2]).multiplyScalar(field.intensity);
|
|
105
|
+
entry.draw.radius = field.radius;
|
|
106
|
+
entry.draw.bands = field.bands;
|
|
107
|
+
entry.draw.softness = field.softness;
|
|
108
|
+
entry.draw.castShadows = field.castShadows ? 1 : 0;
|
|
109
|
+
const center = projection === 'isometric' ? projectIsometric(field.x, field.y) : { x: field.x, y: field.y };
|
|
110
|
+
const footprint = lightFootprint(field.radius, projection);
|
|
111
|
+
entry.mesh.position.set(center.x, center.y, 0);
|
|
112
|
+
entry.mesh.scale.set(Math.max(footprint.width, 1e-6), Math.max(footprint.height, 1e-6), 1);
|
|
113
|
+
entry.mesh.visible = field.radius > 0 && field.intensity > 0;
|
|
114
|
+
}
|
|
115
|
+
createMesh(light) {
|
|
116
|
+
const mesh = new THREE.Mesh(this.geometry, this.material);
|
|
117
|
+
mesh.frustumCulled = false;
|
|
118
|
+
const draw = {
|
|
119
|
+
position: new THREE.Vector2(),
|
|
120
|
+
color: new THREE.Vector3(),
|
|
121
|
+
radius: 0,
|
|
122
|
+
bands: 0,
|
|
123
|
+
softness: 0,
|
|
124
|
+
castShadows: 1,
|
|
125
|
+
};
|
|
126
|
+
setLightDraw(mesh, draw);
|
|
127
|
+
this.scene.add(mesh);
|
|
128
|
+
const entry = { mesh, draw };
|
|
129
|
+
this.meshes.set(light, entry);
|
|
130
|
+
return entry;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -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.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as THREE from 'three';
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
2
|
import type { AssetLoader } from './assets/asset-loader.js';
|
|
3
3
|
import type { ParticleBlend, ParticleOverflow } from './components/particle-emitter.js';
|
|
4
4
|
import { type ParticleSpawn } from './particle-pool.js';
|