@aethervtt/shard-sprite 0.0.1
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/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/atlas.d.ts +112 -0
- package/dist/atlas.js +511 -0
- package/dist/clip.d.ts +73 -0
- package/dist/clip.js +278 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +186 -0
- package/dist/lighting-shaders.d.ts +5 -0
- package/dist/lighting-shaders.js +267 -0
- package/dist/lighting.d.ts +44 -0
- package/dist/lighting.js +149 -0
- package/dist/lights2d.d.ts +203 -0
- package/dist/lights2d.js +1093 -0
- package/dist/occluders.d.ts +64 -0
- package/dist/occluders.js +241 -0
- package/dist/outline.d.ts +24 -0
- package/dist/outline.js +183 -0
- package/dist/plugin.d.ts +6 -0
- package/dist/plugin.js +67 -0
- package/dist/preview.d.ts +9 -0
- package/dist/preview.js +71 -0
- package/dist/render.d.ts +202 -0
- package/dist/render.js +1216 -0
- package/dist/shaders.d.ts +2 -0
- package/dist/shaders.js +263 -0
- package/dist/sprite.d.ts +26 -0
- package/dist/sprite.js +67 -0
- package/dist/tilemap.d.ts +94 -0
- package/dist/tilemap.js +267 -0
- package/package.json +39 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
const LIGHT2D_SHADERS = {
|
|
2
|
+
"shard::sprite::light2d::types": `
|
|
3
|
+
const TILE_PIXELS: u32 = 16u;
|
|
4
|
+
/** Per tile: a count, then up to 64 light indices. */
|
|
5
|
+
const TILE_STRIDE: u32 = 65u;
|
|
6
|
+
const TILE_MAX: u32 = 64u;
|
|
7
|
+
/** Angles per shadow row. */
|
|
8
|
+
const SHADOW_RES: u32 = 1024u;
|
|
9
|
+
/** Coarse bins per row (min and max over 32 angles each). */
|
|
10
|
+
const COARSE_RES: u32 = 32u;
|
|
11
|
+
const COARSE_SPAN: f32 = 32.0;
|
|
12
|
+
const TAU: f32 = 6.283185307179586;
|
|
13
|
+
const PI: f32 = 3.141592653589793;
|
|
14
|
+
|
|
15
|
+
struct Light2d {
|
|
16
|
+
pos: vec2f,
|
|
17
|
+
height: f32,
|
|
18
|
+
radius: f32,
|
|
19
|
+
/** Color \xD7 intensity. */
|
|
20
|
+
color: vec3f,
|
|
21
|
+
falloff: f32,
|
|
22
|
+
/** Spot direction (unit), cos of the inner and outer half-angles; points: cos_outer < -1. */
|
|
23
|
+
dir: vec2f,
|
|
24
|
+
cos_inner: f32,
|
|
25
|
+
cos_outer: f32,
|
|
26
|
+
/** Shadow row, or -1. */
|
|
27
|
+
row: i32,
|
|
28
|
+
softness: f32,
|
|
29
|
+
layers: u32,
|
|
30
|
+
/** 1 / radius\xB2. */
|
|
31
|
+
inv_r2: f32,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
struct Lighting2dView {
|
|
35
|
+
/** Ambient \xD7 intensity (rgb), 0. */
|
|
36
|
+
ambient: vec4f,
|
|
37
|
+
/** Lights, tiles across, tiles down, shadowed lights. */
|
|
38
|
+
counts: vec4u,
|
|
39
|
+
/** Occluder segments, 0, 0, 0. */
|
|
40
|
+
extra: vec4u,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
struct Segment2d {
|
|
44
|
+
a: vec2f,
|
|
45
|
+
b: vec2f,
|
|
46
|
+
/** How far light reaches into the occluder (added to the stored distance). */
|
|
47
|
+
penetration: f32,
|
|
48
|
+
layers: u32,
|
|
49
|
+
_pad0: u32,
|
|
50
|
+
_pad1: u32,
|
|
51
|
+
}`,
|
|
52
|
+
"shard::sprite::light2d": `
|
|
53
|
+
import shard::sprite::light2d::types::{ Light2d, Lighting2dView, TILE_PIXELS, TILE_STRIDE, TILE_MAX, SHADOW_RES, COARSE_RES, COARSE_SPAN, TAU, PI };
|
|
54
|
+
|
|
55
|
+
@group(3) @binding(0) var<uniform> light_view: Lighting2dView;
|
|
56
|
+
@group(3) @binding(1) var<storage, read> lights: array<Light2d>;
|
|
57
|
+
@group(3) @binding(2) var<storage, read> light_tiles: array<u32>;
|
|
58
|
+
@group(3) @binding(3) var<storage, read> shadow_map: array<u32>;
|
|
59
|
+
/** Per row, per 32 angles: the nearest and farthest occluder distance. */
|
|
60
|
+
@group(3) @binding(4) var<storage, read> shadow_coarse: array<vec2f>;
|
|
61
|
+
|
|
62
|
+
/** Min and max over the coarse bin holding continuous bin coordinate x. */
|
|
63
|
+
fn coarse_at(row: u32, x: f32) -> vec2f {
|
|
64
|
+
let n = i32(COARSE_RES);
|
|
65
|
+
let c = i32(floor(x / COARSE_SPAN));
|
|
66
|
+
return shadow_coarse[row * COARSE_RES + u32(((c % n) + n) % n)];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Nearest occluder distance (plus penetration) in angle bin k of a shadow row. */
|
|
70
|
+
fn occluder_at(row: u32, k: i32) -> f32 {
|
|
71
|
+
let n = i32(SHADOW_RES);
|
|
72
|
+
let w = ((k % n) + n) % n;
|
|
73
|
+
return bitcast<f32>(shadow_map[row + u32(w)]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A filtered depth test at continuous bin coordinate x: blends the two nearest bins' results. */
|
|
77
|
+
fn lit_at(row: u32, x: f32, d: f32) -> f32 {
|
|
78
|
+
let k = floor(x - 0.5);
|
|
79
|
+
let a = select(0.0, 1.0, d <= occluder_at(row, i32(k)));
|
|
80
|
+
let b = select(0.0, 1.0, d <= occluder_at(row, i32(k) + 1));
|
|
81
|
+
return mix(a, b, x - 0.5 - k);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* How lit a fragment d from the light is (0..1). Hard shadows take one tap. Soft ones search
|
|
86
|
+
* for blockers across the widest penumbra softness allows, then filter across a kernel whose width
|
|
87
|
+
* grows with softness \xD7 (d \u2212 blocker) / blocker, so penumbras widen away from the occluder.
|
|
88
|
+
*/
|
|
89
|
+
fn shadow2d(l: Light2d, d: f32, to_frag: vec2f) -> f32 {
|
|
90
|
+
let step = TAU / f32(SHADOW_RES);
|
|
91
|
+
let fb = (atan2(to_frag.y, to_frag.x) + PI) / step;
|
|
92
|
+
let row = u32(l.row) * SHADOW_RES;
|
|
93
|
+
if (l.softness <= 0.0) {
|
|
94
|
+
return select(0.0, 1.0, d <= occluder_at(row, i32(floor(fb))));
|
|
95
|
+
}
|
|
96
|
+
// Blocker search: 8 taps across the widest penumbra softness allows at this distance. The
|
|
97
|
+
// search window spans at most three coarse bins; their min and max settle "fully lit" and "in
|
|
98
|
+
// the umbra" without the taps, with the same answer the taps would give.
|
|
99
|
+
let search = min(l.softness / max(d, 1e-3) / step, 32.0);
|
|
100
|
+
let a = coarse_at(u32(l.row), fb - search);
|
|
101
|
+
let b = coarse_at(u32(l.row), fb);
|
|
102
|
+
let c = coarse_at(u32(l.row), fb + search);
|
|
103
|
+
if (d <= min(a.x, min(b.x, c.x))) { return 1.0; }
|
|
104
|
+
if (d > max(a.y, max(b.y, c.y))) { return 0.0; }
|
|
105
|
+
var blockers = 0.0;
|
|
106
|
+
var sum = 0.0;
|
|
107
|
+
for (var i = 0; i < 8; i++) {
|
|
108
|
+
let o = occluder_at(row, i32(floor(fb + (f32(i) / 7.0 * 2.0 - 1.0) * search)));
|
|
109
|
+
if (o < d) {
|
|
110
|
+
sum += o;
|
|
111
|
+
blockers += 1.0;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (blockers == 0.0) { return 1.0; }
|
|
115
|
+
// Every tap blocked: in the umbra.
|
|
116
|
+
if (blockers == 8.0) { return 0.0; }
|
|
117
|
+
let blocker = sum / blockers;
|
|
118
|
+
let width = min(l.softness * (d - blocker) / max(blocker, 1e-3) / d / step, 32.0);
|
|
119
|
+
var lit = 0.0;
|
|
120
|
+
for (var i = 0; i < 24; i++) {
|
|
121
|
+
lit += lit_at(row, fb + (f32(i) / 23.0 * 2.0 - 1.0) * width, d);
|
|
122
|
+
}
|
|
123
|
+
return lit / 24.0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Light reaching a fragment: ambient plus every light of its screen tile that reaches it, with
|
|
128
|
+
* smooth falloff, the spot cone, N\xB7L from the normal and the light's height (normal-mapped
|
|
129
|
+
* sprites only; others get plain radial falloff), and shadows.
|
|
130
|
+
*/
|
|
131
|
+
fn light2d(frag: vec2f, world: vec2f, n: vec3f, has_normal: bool, bit: u32) -> vec3f {
|
|
132
|
+
var sum = light_view.ambient.rgb;
|
|
133
|
+
let tiles_x = light_view.counts.y;
|
|
134
|
+
let tiles_y = light_view.counts.z;
|
|
135
|
+
let tx = min(u32(max(frag.x, 0.0)) / TILE_PIXELS, tiles_x - 1u);
|
|
136
|
+
let ty = min(u32(max(frag.y, 0.0)) / TILE_PIXELS, tiles_y - 1u);
|
|
137
|
+
let base = (ty * tiles_x + tx) * TILE_STRIDE;
|
|
138
|
+
let count = min(light_tiles[base], TILE_MAX);
|
|
139
|
+
for (var k = 0u; k < count; k++) {
|
|
140
|
+
let l = lights[light_tiles[base + 1u + k]];
|
|
141
|
+
if ((l.layers & bit) == 0u) { continue; }
|
|
142
|
+
let delta = l.pos - world;
|
|
143
|
+
let q = 1.0 - dot(delta, delta) * l.inv_r2;
|
|
144
|
+
if (q <= 0.0) { continue; }
|
|
145
|
+
let d = length(delta);
|
|
146
|
+
var att = pow(q, l.falloff);
|
|
147
|
+
if (l.cos_outer > -1.5 && d > 1e-4) {
|
|
148
|
+
att *= smoothstep(l.cos_outer, l.cos_inner, dot(-delta / d, l.dir));
|
|
149
|
+
}
|
|
150
|
+
if (has_normal) {
|
|
151
|
+
att *= max(0.0, dot(n, normalize(vec3f(delta, l.height))));
|
|
152
|
+
}
|
|
153
|
+
if (l.row >= 0 && att > 0.0) {
|
|
154
|
+
att *= shadow2d(l, d, -delta);
|
|
155
|
+
}
|
|
156
|
+
sum += l.color * att;
|
|
157
|
+
}
|
|
158
|
+
return sum;
|
|
159
|
+
}`,
|
|
160
|
+
"shard::sprite::light2d::compute": `
|
|
161
|
+
import shard::sprite::light2d::types::{ Light2d, Lighting2dView, Segment2d, TILE_PIXELS, TILE_STRIDE, TILE_MAX, SHADOW_RES, COARSE_RES, TAU, PI };
|
|
162
|
+
|
|
163
|
+
@group(0) @binding(0) var<uniform> light_view: Lighting2dView;
|
|
164
|
+
@group(0) @binding(1) var<storage, read> lights: array<Light2d>;
|
|
165
|
+
/** Per light: its circle in pixels (center xy, radius), 0. */
|
|
166
|
+
@group(0) @binding(2) var<storage, read> circles: array<vec4f>;
|
|
167
|
+
@group(0) @binding(3) var<storage, read_write> light_tiles: array<u32>;
|
|
168
|
+
@group(0) @binding(4) var<storage, read_write> shadow_map: array<atomic<u32>>;
|
|
169
|
+
@group(0) @binding(5) var<storage, read> segments: array<Segment2d>;
|
|
170
|
+
/** The light index of each shadow row. */
|
|
171
|
+
@group(0) @binding(6) var<storage, read> shadowed: array<u32>;
|
|
172
|
+
@group(0) @binding(7) var<storage, read_write> shadow_coarse: array<vec2f>;
|
|
173
|
+
|
|
174
|
+
/** One thread per 16\xD716-pixel tile: the lights whose circle reaches it, up to 64. */
|
|
175
|
+
@compute @workgroup_size(64) fn bin(@builtin(global_invocation_id) id: vec3u) {
|
|
176
|
+
let tiles_x = light_view.counts.y;
|
|
177
|
+
let tiles = tiles_x * light_view.counts.z;
|
|
178
|
+
let t = id.x;
|
|
179
|
+
if (t >= tiles) { return; }
|
|
180
|
+
let lo = vec2f(f32((t % tiles_x) * TILE_PIXELS), f32((t / tiles_x) * TILE_PIXELS));
|
|
181
|
+
let hi = lo + vec2f(f32(TILE_PIXELS));
|
|
182
|
+
let base = t * TILE_STRIDE;
|
|
183
|
+
var n = 0u;
|
|
184
|
+
for (var i = 0u; i < light_view.counts.x; i++) {
|
|
185
|
+
let c = circles[i];
|
|
186
|
+
let q = clamp(c.xy, lo, hi) - c.xy;
|
|
187
|
+
if (dot(q, q) > c.z * c.z) { continue; }
|
|
188
|
+
if (n < TILE_MAX) {
|
|
189
|
+
light_tiles[base + 1u + n] = i;
|
|
190
|
+
}
|
|
191
|
+
n++;
|
|
192
|
+
}
|
|
193
|
+
// The full count (possibly over the cap) for stats; shading clamps to 64.
|
|
194
|
+
light_tiles[base] = n;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Every shadow texel starts at "nothing in the way". */
|
|
198
|
+
@compute @workgroup_size(64) fn clear(@builtin(global_invocation_id) id: vec3u) {
|
|
199
|
+
if (id.x >= light_view.counts.w * SHADOW_RES) { return; }
|
|
200
|
+
atomicStore(&shadow_map[id.x], 0x7f7fffffu);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* One thread per (segment, shadowed light): for each angle bin the segment covers as seen from
|
|
205
|
+
* the light, the distance along that bin's ray to the segment, kept with atomicMin on the
|
|
206
|
+
* distance's bits (positive floats sort like their bits). Bins holding an endpoint count too, with
|
|
207
|
+
* the ray clamped into the segment, so segments thinner than a bin still cast.
|
|
208
|
+
*/
|
|
209
|
+
@compute @workgroup_size(64) fn shadows(@builtin(global_invocation_id) id: vec3u) {
|
|
210
|
+
if (id.x >= light_view.extra.x || id.y >= light_view.counts.w) { return; }
|
|
211
|
+
let s = segments[id.x];
|
|
212
|
+
let l = lights[shadowed[id.y]];
|
|
213
|
+
if ((s.layers & l.layers) == 0u) { return; }
|
|
214
|
+
let a = s.a - l.pos;
|
|
215
|
+
let b = s.b - l.pos;
|
|
216
|
+
let e = b - a;
|
|
217
|
+
// Skip segments outside the light's circle.
|
|
218
|
+
let len2 = dot(e, e);
|
|
219
|
+
let t0 = select(0.0, clamp(-dot(a, e) / len2, 0.0, 1.0), len2 > 0.0);
|
|
220
|
+
let near = a + e * t0;
|
|
221
|
+
if (dot(near, near) >= l.radius * l.radius) { return; }
|
|
222
|
+
let side = a.x * b.y - a.y * b.x;
|
|
223
|
+
// A segment through the light (or edge-on to it) casts nothing useful.
|
|
224
|
+
if (abs(side) < 1e-9) { return; }
|
|
225
|
+
var start = atan2(a.y, a.x);
|
|
226
|
+
var span = atan2(b.y, b.x) - start;
|
|
227
|
+
if (span > PI) { span -= TAU; }
|
|
228
|
+
if (span < -PI) { span += TAU; }
|
|
229
|
+
if (span < 0.0) {
|
|
230
|
+
start += span;
|
|
231
|
+
span = -span;
|
|
232
|
+
}
|
|
233
|
+
let step = TAU / f32(SHADOW_RES);
|
|
234
|
+
let first = i32(floor((start + PI) / step));
|
|
235
|
+
let last = min(i32(floor((start + span + PI) / step)), first + i32(SHADOW_RES) - 1);
|
|
236
|
+
let row = id.y * SHADOW_RES;
|
|
237
|
+
let n = i32(SHADOW_RES);
|
|
238
|
+
for (var k = first; k <= last; k++) {
|
|
239
|
+
let theta = clamp(-PI + (f32(k) + 0.5) * step, start, start + span);
|
|
240
|
+
let dir = vec2f(cos(theta), sin(theta));
|
|
241
|
+
let den = dir.x * e.y - dir.y * e.x;
|
|
242
|
+
if (abs(den) < 1e-12) { continue; }
|
|
243
|
+
let t = (a.x * e.y - a.y * e.x) / den;
|
|
244
|
+
if (t <= 0.0) { continue; }
|
|
245
|
+
let w = ((k % n) + n) % n;
|
|
246
|
+
atomicMin(&shadow_map[row + u32(w)], bitcast<u32>(t + s.penetration));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** One thread per (row, coarse bin): the min and max of its 32 angles. */
|
|
251
|
+
@compute @workgroup_size(64) fn coarse(@builtin(global_invocation_id) id: vec3u) {
|
|
252
|
+
if (id.x >= light_view.counts.w * COARSE_RES) { return; }
|
|
253
|
+
let row = id.x / COARSE_RES;
|
|
254
|
+
let first = row * SHADOW_RES + (id.x % COARSE_RES) * (SHADOW_RES / COARSE_RES);
|
|
255
|
+
var lo = 3.4028234e38;
|
|
256
|
+
var hi = 0.0;
|
|
257
|
+
for (var k = 0u; k < SHADOW_RES / COARSE_RES; k++) {
|
|
258
|
+
let v = bitcast<f32>(atomicLoad(&shadow_map[first + k]));
|
|
259
|
+
lo = min(lo, v);
|
|
260
|
+
hi = max(hi, v);
|
|
261
|
+
}
|
|
262
|
+
shadow_coarse[id.x] = vec2f(lo, hi);
|
|
263
|
+
}`
|
|
264
|
+
};
|
|
265
|
+
export {
|
|
266
|
+
LIGHT2D_SHADERS
|
|
267
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export declare const PointLight2d: import("@aethervtt/shard-core").ComponentDef<{
|
|
2
|
+
color: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Color, "f32">;
|
|
3
|
+
intensity: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
4
|
+
radius: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
5
|
+
falloff: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
6
|
+
height: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
7
|
+
shadows: import("@aethervtt/shard-core").FieldType<boolean, "u8">;
|
|
8
|
+
softness: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
9
|
+
layers: import("@aethervtt/shard-core").FieldType<number, "u32">;
|
|
10
|
+
}>;
|
|
11
|
+
export declare const SpotLight2d: import("@aethervtt/shard-core").ComponentDef<{
|
|
12
|
+
readonly color: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Color, "f32">;
|
|
13
|
+
readonly intensity: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
14
|
+
readonly radius: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
15
|
+
readonly falloff: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
16
|
+
readonly height: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
17
|
+
readonly shadows: import("@aethervtt/shard-core").FieldType<boolean, "u8">;
|
|
18
|
+
readonly softness: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
19
|
+
readonly layers: import("@aethervtt/shard-core").FieldType<number, "u32">;
|
|
20
|
+
readonly innerAngle: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
21
|
+
readonly outerAngle: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
22
|
+
}>;
|
|
23
|
+
export declare const Lighting2d: import("@aethervtt/shard-core").ComponentDef<{
|
|
24
|
+
readonly ambient: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Color, "f32">;
|
|
25
|
+
readonly ambientIntensity: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
26
|
+
readonly maxLights: import("@aethervtt/shard-core").FieldType<number, "u16">;
|
|
27
|
+
readonly maxShadowed: import("@aethervtt/shard-core").FieldType<number, "u16">;
|
|
28
|
+
}>;
|
|
29
|
+
export declare const SpriteLighting: import("@aethervtt/shard-core").ComponentDef<{
|
|
30
|
+
readonly normal: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").AssetRef<"Texture"> | null, "object">;
|
|
31
|
+
readonly emissive: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
32
|
+
readonly normalStrength: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
33
|
+
}>;
|
|
34
|
+
export declare const OCCLUDER_SHAPES: readonly ['box', 'circle', 'polygon', 'sprite', 'collider'];
|
|
35
|
+
export type OccluderShape = (typeof OCCLUDER_SHAPES)[number];
|
|
36
|
+
export declare const LightOccluder2d: import("@aethervtt/shard-core").ComponentDef<{
|
|
37
|
+
readonly shape: import("@aethervtt/shard-core").FieldType<"box" | "circle" | "collider" | "polygon" | "sprite", "u8">;
|
|
38
|
+
readonly size: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Vec2, "f32">;
|
|
39
|
+
readonly points: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Vec2[], "object">;
|
|
40
|
+
readonly lightPenetration: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
41
|
+
readonly layers: import("@aethervtt/shard-core").FieldType<number, "u32">;
|
|
42
|
+
}>;
|
|
43
|
+
/** The light-layer bit of a sprite layer: bands of 64 layers from −1024. */
|
|
44
|
+
export declare function layerBit(layer: number): number;
|
package/dist/lighting.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { defineComponent, t } from "@aethervtt/shard-core";
|
|
2
|
+
import { Visibility } from "@aethervtt/shard-render";
|
|
3
|
+
import { Transform } from "@aethervtt/shard-transform";
|
|
4
|
+
const lightFields = {
|
|
5
|
+
color: t.color({ default: [1, 1, 1, 1], description: "Light color (linear)." }),
|
|
6
|
+
intensity: t.f32({
|
|
7
|
+
default: 1,
|
|
8
|
+
min: 0,
|
|
9
|
+
description: "Sprite units: 1 shows a sprite it fully lights at its texture color. Above 1 overbrightens (and blooms)."
|
|
10
|
+
}),
|
|
11
|
+
radius: t.f32({
|
|
12
|
+
default: 5,
|
|
13
|
+
min: 0,
|
|
14
|
+
unit: "m",
|
|
15
|
+
description: "World units: the light reaches zero here."
|
|
16
|
+
}),
|
|
17
|
+
falloff: t.f32({
|
|
18
|
+
default: 2,
|
|
19
|
+
min: 0,
|
|
20
|
+
description: "Exponent of the smooth window (1 \u2212 (d/radius)\xB2)^falloff: higher is tighter."
|
|
21
|
+
}),
|
|
22
|
+
height: t.f32({
|
|
23
|
+
default: 1,
|
|
24
|
+
min: 0,
|
|
25
|
+
unit: "m",
|
|
26
|
+
description: "Height above the sprite plane, for normal maps: low grazes the surface, high lights it flat."
|
|
27
|
+
}),
|
|
28
|
+
shadows: t.bool({ description: "Cast shadows from LightOccluder2d shapes and occluding tiles." }),
|
|
29
|
+
softness: t.f32({
|
|
30
|
+
default: 0.1,
|
|
31
|
+
min: 0,
|
|
32
|
+
unit: "m",
|
|
33
|
+
description: "Emitter size in world units: penumbras widen with distance from the occluder. 0: hard shadows."
|
|
34
|
+
}),
|
|
35
|
+
layers: t.u32({
|
|
36
|
+
default: 4294967295,
|
|
37
|
+
description: "Which sprite layers it lights: bit k covers layers k\xD764 \u2212 1024 to k\xD764 \u2212 961. All bits: every layer."
|
|
38
|
+
})
|
|
39
|
+
};
|
|
40
|
+
const PointLight2d = defineComponent("sprite/PointLight2d", lightFields, {
|
|
41
|
+
description: "A 2D light at the entity: lights sprites and tiles under a Lighting2d camera, with optional shadows.",
|
|
42
|
+
requires: [Transform, Visibility]
|
|
43
|
+
});
|
|
44
|
+
const SpotLight2d = defineComponent(
|
|
45
|
+
"sprite/SpotLight2d",
|
|
46
|
+
{
|
|
47
|
+
...lightFields,
|
|
48
|
+
innerAngle: t.f32({
|
|
49
|
+
default: 30,
|
|
50
|
+
min: 0,
|
|
51
|
+
max: 180,
|
|
52
|
+
unit: "deg",
|
|
53
|
+
description: "Half-angle of full brightness, from the entity\u2019s +X."
|
|
54
|
+
}),
|
|
55
|
+
outerAngle: t.f32({
|
|
56
|
+
default: 45,
|
|
57
|
+
min: 0,
|
|
58
|
+
max: 180,
|
|
59
|
+
unit: "deg",
|
|
60
|
+
description: "Half-angle where the cone fades to zero."
|
|
61
|
+
})
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
description: "A 2D cone light pointing along the entity\u2019s +X (rotate the entity to aim it), like a flashlight.",
|
|
65
|
+
requires: [Transform, Visibility]
|
|
66
|
+
}
|
|
67
|
+
);
|
|
68
|
+
const Lighting2d = defineComponent(
|
|
69
|
+
"sprite/Lighting2d",
|
|
70
|
+
{
|
|
71
|
+
ambient: t.color({
|
|
72
|
+
default: [0.08, 0.08, 0.1, 1],
|
|
73
|
+
description: "Light every lit sprite gets without any light (linear)."
|
|
74
|
+
}),
|
|
75
|
+
ambientIntensity: t.f32({ default: 1, min: 0, description: "Multiplies ambient." }),
|
|
76
|
+
maxLights: t.u16({ default: 256, min: 1, max: 1024, description: "Visible lights per view." }),
|
|
77
|
+
maxShadowed: t.u16({
|
|
78
|
+
default: 64,
|
|
79
|
+
max: 64,
|
|
80
|
+
description: "Shadowed lights per view: the ones nearest the view center win."
|
|
81
|
+
})
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
description: "On a camera: lights the 2D world it sees with PointLight2d and SpotLight2d. Without it, sprites draw unlit."
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
const SpriteLighting = defineComponent(
|
|
88
|
+
"sprite/SpriteLighting",
|
|
89
|
+
{
|
|
90
|
+
normal: t.handle("Texture", {
|
|
91
|
+
description: "Normal map for a plain texture sprite (atlas sprites use TextureAtlas.normals). Tangent space, +Y up."
|
|
92
|
+
}),
|
|
93
|
+
emissive: t.f32({
|
|
94
|
+
min: 0,
|
|
95
|
+
description: "Glow: \xD7 the sprite color, added after lighting (eyes, screens, embers)."
|
|
96
|
+
}),
|
|
97
|
+
normalStrength: t.f32({
|
|
98
|
+
default: 1,
|
|
99
|
+
min: 0,
|
|
100
|
+
description: "Scales the normal map\u2019s tilt: 0 lights it flat."
|
|
101
|
+
})
|
|
102
|
+
},
|
|
103
|
+
{ description: "Per-sprite lighting options: a normal map, emissive glow, normal strength." }
|
|
104
|
+
);
|
|
105
|
+
const OCCLUDER_SHAPES = ["box", "circle", "polygon", "sprite", "collider"];
|
|
106
|
+
const LightOccluder2d = defineComponent(
|
|
107
|
+
"sprite/LightOccluder2d",
|
|
108
|
+
{
|
|
109
|
+
shape: t.enum(OCCLUDER_SHAPES, {
|
|
110
|
+
description: "box: size. circle: size[0] is the radius. polygon: points. sprite: the sprite's alpha outline (atlas with outlines: true), else its rectangle. collider: the entity's physics/Collider (cuboid, ball, capsule, convex)."
|
|
111
|
+
}),
|
|
112
|
+
size: t.vec2({
|
|
113
|
+
default: [1, 1],
|
|
114
|
+
min: 0,
|
|
115
|
+
unit: "m",
|
|
116
|
+
description: "box: full size. circle: size[0] is the radius."
|
|
117
|
+
}),
|
|
118
|
+
points: t.list(t.vec2, {
|
|
119
|
+
description: "polygon: local-space points, closed (the last joins the first)."
|
|
120
|
+
}),
|
|
121
|
+
lightPenetration: t.f32({
|
|
122
|
+
default: 0.05,
|
|
123
|
+
min: 0,
|
|
124
|
+
unit: "m",
|
|
125
|
+
description: "How far light reaches into the occluder, so its own face toward the light stays lit."
|
|
126
|
+
}),
|
|
127
|
+
layers: t.u32({
|
|
128
|
+
default: 4294967295,
|
|
129
|
+
description: "Which lights it blocks, matching PointLight2d.layers."
|
|
130
|
+
})
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
description: "Blocks 2D light: shadowed lights (shadows: true) cast shadows from its shape.",
|
|
134
|
+
requires: [Transform]
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
function layerBit(layer) {
|
|
138
|
+
const band = Math.min(31, Math.max(0, layer + 1024 >> 6));
|
|
139
|
+
return 1 << band >>> 0;
|
|
140
|
+
}
|
|
141
|
+
export {
|
|
142
|
+
LightOccluder2d,
|
|
143
|
+
Lighting2d,
|
|
144
|
+
OCCLUDER_SHAPES,
|
|
145
|
+
PointLight2d,
|
|
146
|
+
SpotLight2d,
|
|
147
|
+
SpriteLighting,
|
|
148
|
+
layerBit
|
|
149
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { type Entity, type Query, type World } from '@aethervtt/shard-core';
|
|
2
|
+
import { GpuBuffer, type GpuContext } from '@aethervtt/shard-gpu';
|
|
3
|
+
import { type NodeDescriptor } from '@aethervtt/shard-render';
|
|
4
|
+
import { type TileLayer } from './tilemap.js';
|
|
5
|
+
/** Floats per light record (see `Light2d` in lighting-shaders.ts). */
|
|
6
|
+
export declare const LIGHT2D_FLOATS = 16;
|
|
7
|
+
/** Floats per occluder segment record (`Segment2d`). */
|
|
8
|
+
export declare const SEGMENT_FLOATS = 8;
|
|
9
|
+
export declare const TILE_PIXELS = 16;
|
|
10
|
+
export declare const TILE_MAX = 64;
|
|
11
|
+
/** u32s per tile in the tile buffer: a count, then TILE_MAX light indices. */
|
|
12
|
+
export declare const TILE_STRIDE: number;
|
|
13
|
+
/** Angles per shadow row. */
|
|
14
|
+
export declare const SHADOW_RES = 1024;
|
|
15
|
+
/** Coarse bins per shadow row (min and max of 32 angles each). */
|
|
16
|
+
export declare const COARSE_RES = 32;
|
|
17
|
+
export declare const MAX_SHADOWED = 64;
|
|
18
|
+
/**
|
|
19
|
+
* A light's circle in pixels (y down), written to `out` at `o` (x, y, radius, 0). Returns whether it
|
|
20
|
+
* reaches the view. Works for any camera: the radius is the larger of the projected x and y offsets.
|
|
21
|
+
*/
|
|
22
|
+
export declare function lightCircle(viewProj: ArrayLike<number>, width: number, height: number, x: number, y: number, z: number, r: number, out: Float32Array, o: number): boolean;
|
|
23
|
+
/** Bins light circles into 16×16-pixel tiles, like the GPU: a full count and up to 64 indices. */
|
|
24
|
+
export declare function binLightsCpu(circles: Float32Array, count: number, tilesX: number, tilesY: number): {
|
|
25
|
+
counts: Uint32Array;
|
|
26
|
+
indices: Uint32Array;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* One light's shadow row on the CPU, like the GPU kernel: per angle bin, the nearest segment
|
|
30
|
+
* distance plus its penetration. `segments` holds SEGMENT_FLOATS per segment.
|
|
31
|
+
*/
|
|
32
|
+
export declare function shadowRowCpu(lx: number, ly: number, radius: number, layers: number, segments: Float32Array, count: number, out: Float32Array): Float32Array;
|
|
33
|
+
/** How lit a point is by a shadow row: the fragment shader's `shadow2d`, on the CPU. */
|
|
34
|
+
export declare function shadowFactorCpu(row: Float32Array, softness: number, d: number, toX: number, toY: number): number;
|
|
35
|
+
/** One occluder entity's segments, cached in world space until it changes. */
|
|
36
|
+
interface OccluderRecord {
|
|
37
|
+
entity: Entity;
|
|
38
|
+
local: number[];
|
|
39
|
+
world: Float32Array;
|
|
40
|
+
/** minX, minY, maxX, maxY. */
|
|
41
|
+
bounds: Float32Array;
|
|
42
|
+
penetration: number;
|
|
43
|
+
layers: number;
|
|
44
|
+
seen: number;
|
|
45
|
+
}
|
|
46
|
+
/** One occluding tile layer: boundary edges per chunk, rebuilt only for edited chunks. */
|
|
47
|
+
interface TileOccluder {
|
|
48
|
+
entity: Entity;
|
|
49
|
+
layerIndex: number;
|
|
50
|
+
layer: TileLayer;
|
|
51
|
+
chunkSize: number;
|
|
52
|
+
chunksX: number;
|
|
53
|
+
chunksY: number;
|
|
54
|
+
editBase: number;
|
|
55
|
+
cursor: number;
|
|
56
|
+
local: number[][];
|
|
57
|
+
world: Float32Array[];
|
|
58
|
+
bounds: Float32Array;
|
|
59
|
+
affine: Float32Array;
|
|
60
|
+
tileSize: [number, number];
|
|
61
|
+
/** Light reaches three quarters of a tile into walls, so their faces toward it stay lit. */
|
|
62
|
+
penetration: number;
|
|
63
|
+
seen: number;
|
|
64
|
+
}
|
|
65
|
+
/** Occluders in world space, shared by every lit view. */
|
|
66
|
+
export declare class OccluderStore {
|
|
67
|
+
readonly records: OccluderRecord[];
|
|
68
|
+
readonly index: Map<number, number>;
|
|
69
|
+
readonly tiles: Map<string, TileOccluder>;
|
|
70
|
+
/** Bumps whenever any occluder's world segments change. */
|
|
71
|
+
version: number;
|
|
72
|
+
/** Tile chunks whose edges were rebuilt in the last update. */
|
|
73
|
+
chunkRebuilds: number;
|
|
74
|
+
frame: number;
|
|
75
|
+
/** Errors already logged, per entity and code. */
|
|
76
|
+
readonly logged: Set<string>;
|
|
77
|
+
/** Segments across all occluders (entities and tile chunks). */
|
|
78
|
+
get segmentCount(): number;
|
|
79
|
+
}
|
|
80
|
+
/** Brings occluder entities and occluding tile layers up to date. */
|
|
81
|
+
/** The queries the lighting system reads, built once in its setup. */
|
|
82
|
+
interface LightQueries {
|
|
83
|
+
cameras: Query;
|
|
84
|
+
points: Query;
|
|
85
|
+
spots: Query;
|
|
86
|
+
occluders: Query;
|
|
87
|
+
tilemaps: Query;
|
|
88
|
+
}
|
|
89
|
+
/** One lit view: its lights, shadow rows, occluder segments, and GPU buffers. */
|
|
90
|
+
export declare class LightView2d {
|
|
91
|
+
readonly name: string;
|
|
92
|
+
camera: Entity;
|
|
93
|
+
lights: Float32Array<ArrayBuffer>;
|
|
94
|
+
lightBits: Uint32Array<ArrayBuffer>;
|
|
95
|
+
circles: Float32Array<ArrayBuffer>;
|
|
96
|
+
entities: Float64Array<ArrayBuffer>;
|
|
97
|
+
count: number;
|
|
98
|
+
/** Light index per shadow row. */
|
|
99
|
+
readonly shadowed: Uint32Array<ArrayBuffer>;
|
|
100
|
+
shadowedCount: number;
|
|
101
|
+
segments: Float32Array<ArrayBuffer>;
|
|
102
|
+
segmentBits: Uint32Array<ArrayBuffer>;
|
|
103
|
+
segmentCount: number;
|
|
104
|
+
tilesX: number;
|
|
105
|
+
tilesY: number;
|
|
106
|
+
readonly uniformData: Float32Array<ArrayBuffer>;
|
|
107
|
+
readonly uniformBits: Uint32Array<ArrayBuffer>;
|
|
108
|
+
/** Stats for describe. */
|
|
109
|
+
visible: number;
|
|
110
|
+
culled: number;
|
|
111
|
+
dropped: number;
|
|
112
|
+
demoted: number;
|
|
113
|
+
uploadedBytes: number;
|
|
114
|
+
frame: number;
|
|
115
|
+
private segmentKey;
|
|
116
|
+
gpu: ViewGpu | undefined;
|
|
117
|
+
constructor(name: string);
|
|
118
|
+
ensureLights(n: number): void;
|
|
119
|
+
ensureSegments(n: number): void;
|
|
120
|
+
/** Uploads what changed. Segments go up only when the selection or an occluder changed. */
|
|
121
|
+
upload(gpu: GpuContext, segmentKey: number): void;
|
|
122
|
+
}
|
|
123
|
+
interface ViewGpu {
|
|
124
|
+
generation: number;
|
|
125
|
+
uniform: GpuBuffer;
|
|
126
|
+
lights: GpuBuffer;
|
|
127
|
+
circles: GpuBuffer;
|
|
128
|
+
tiles: GpuBuffer;
|
|
129
|
+
shadowMap: GpuBuffer;
|
|
130
|
+
shadowCoarse: GpuBuffer;
|
|
131
|
+
segments: GpuBuffer;
|
|
132
|
+
shadowed: GpuBuffer;
|
|
133
|
+
computeGroup?: {
|
|
134
|
+
key: string;
|
|
135
|
+
group: GPUBindGroup;
|
|
136
|
+
};
|
|
137
|
+
renderGroup?: {
|
|
138
|
+
key: string;
|
|
139
|
+
group: GPUBindGroup;
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** 2D lighting state: lit views and the occluders they share. */
|
|
143
|
+
export declare class Lights2dStore {
|
|
144
|
+
readonly views: Map<string, LightView2d>;
|
|
145
|
+
readonly occluders: OccluderStore;
|
|
146
|
+
/** Budget warnings already logged, per view and code. */
|
|
147
|
+
readonly warned: Set<string>;
|
|
148
|
+
}
|
|
149
|
+
export declare const Lights2d: import("@aethervtt/shard-core").ResourceDef<Lights2dStore>;
|
|
150
|
+
/**
|
|
151
|
+
* Per lit camera (Lighting2d): extracts visible lights (circle vs view), picks shadow rows,
|
|
152
|
+
* selects the occluder segments that reach them, and uploads. Does nothing without a lit camera.
|
|
153
|
+
*/
|
|
154
|
+
export declare const prepareLights2d: import("@aethervtt/shard-core").SystemDef<LightQueries>;
|
|
155
|
+
/** The bind group layout lit sprite and tilemap pipelines take at group 3. */
|
|
156
|
+
export declare function lights2dLayout(gpu: GpuContext): GPUBindGroupLayout;
|
|
157
|
+
/** A lit view's light state, or undefined when the view isn't lit. */
|
|
158
|
+
export declare function litView(world: World, view: string): LightView2d | undefined;
|
|
159
|
+
/** The group-3 bind group of a lit view's sprite and tilemap draws. */
|
|
160
|
+
export declare function lights2dGroup(gpu: GpuContext, view: LightView2d): GPUBindGroup;
|
|
161
|
+
/**
|
|
162
|
+
* Bins lights into screen tiles, fills the shadow rows, and reduces them to coarse min/max bins,
|
|
163
|
+
* in one compute pass per lit view.
|
|
164
|
+
*/
|
|
165
|
+
export declare function lights2dNode(world: World): NodeDescriptor;
|
|
166
|
+
/** One lit view in `render.describe` → `sprites.lighting.views`. */
|
|
167
|
+
export interface LightViewStats {
|
|
168
|
+
/** Lights uploaded (at most maxLights). */
|
|
169
|
+
lights: number;
|
|
170
|
+
/** Lights that reached the view, before the maxLights budget. */
|
|
171
|
+
visible: number;
|
|
172
|
+
/** Lights outside the view. */
|
|
173
|
+
culled: number;
|
|
174
|
+
/** Lights over maxLights. */
|
|
175
|
+
dropped: number;
|
|
176
|
+
shadowed: number;
|
|
177
|
+
/** Shadowed lights over maxShadowed, drawn without shadows. */
|
|
178
|
+
demoted: number;
|
|
179
|
+
/** Occluder segments uploaded for the shadowed lights. */
|
|
180
|
+
segments: number;
|
|
181
|
+
tiles: number;
|
|
182
|
+
/** Tiles whose lights hit the 64-light cap. */
|
|
183
|
+
tilesAtCap: number;
|
|
184
|
+
maxLightsPerTile: number;
|
|
185
|
+
uploadedBytes: number;
|
|
186
|
+
/** GPU time (timestamp queries), when the device has them. */
|
|
187
|
+
gpuMs: {
|
|
188
|
+
binAndShadows: number | undefined;
|
|
189
|
+
sprites: number | undefined;
|
|
190
|
+
};
|
|
191
|
+
/** The lit lights, in upload order. */
|
|
192
|
+
entities: number[];
|
|
193
|
+
}
|
|
194
|
+
/** `render.describe` → `sprites.lighting`: per lit view, what's lit, culled, and over budget. */
|
|
195
|
+
export declare function describeLights2d(world: World): {
|
|
196
|
+
occluders: number;
|
|
197
|
+
tileLayers: number;
|
|
198
|
+
segments: number;
|
|
199
|
+
chunkRebuilds: number;
|
|
200
|
+
views: Record<string, LightViewStats>;
|
|
201
|
+
} | undefined;
|
|
202
|
+
export declare const lights2dOverlay: import("@aethervtt/shard-render").OverlayDef;
|
|
203
|
+
export {};
|