@moood/render 0.1.3 → 0.1.5

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/engine/gl.js CHANGED
@@ -7,32 +7,120 @@
7
7
  // (like a map), a few per frame, ahead of the camera.
8
8
  const MooodGL = (() => {
9
9
  const I = Moood.internals;
10
- const QUADS = 8192, STRIDE = 10; // per vertex: x y u v r g b a, haze k b
10
+ const QUADS = 8192, STRIDE = 12; // per vertex: x y u v r g b a, haze k b, lit, depth
11
11
 
12
12
  // Everything is drawn in the frame's pixels (engine.js: the frame is a landscape rectangle inside
13
13
  // the canvas, which shows the scene on around it): u_off is where the frame starts in the
14
14
  // canvas, u_canvas the canvas's size.
15
15
  const VS = `#version 300 es
16
- in vec2 a_pos; in vec2 a_uv; in vec4 a_col; in vec2 a_haze;
16
+ in vec2 a_pos; in vec2 a_uv; in vec4 a_col; in vec2 a_haze; in vec2 a_lit;
17
17
  uniform vec2 u_canvas, u_off;
18
- out vec2 v_uv; out vec4 v_col; out vec2 v_haze;
18
+ out vec2 v_uv; out vec4 v_col; out vec2 v_haze; out vec2 v_lit;
19
19
  void main() {
20
- v_uv = a_uv; v_col = a_col; v_haze = a_haze;
20
+ v_uv = a_uv; v_col = a_col; v_haze = a_haze; v_lit = a_lit;
21
21
  vec2 p = a_pos + u_off;
22
22
  gl_Position = vec4(p.x / u_canvas.x * 2.0 - 1.0, 1.0 - p.y / u_canvas.y * 2.0, 0.0, 1.0);
23
23
  }`;
24
+ // A lit scene (engine.js: lookOf) draws into three targets: the colour; what is lit and how far
25
+ // it is (lit·a, lit·depth·a, 0, a — composed like a colour, so the nearest painting wins); and the
26
+ // glows (additive quads: light in the air, covered by what is drawn in front of them later). The
27
+ // final pass lays the light on the colour by the depth, then adds the glows.
24
28
  const FS = `#version 300 es
25
29
  precision mediump float;
26
- in vec2 v_uv; in vec4 v_col; in vec2 v_haze;
30
+ in vec2 v_uv; in vec4 v_col; in vec2 v_haze; in vec2 v_lit;
27
31
  uniform sampler2D u_tex;
28
32
  uniform vec3 u_fog;
29
- out vec4 o;
33
+ layout(location = 0) out vec4 o;
34
+ #ifdef LIT
35
+ layout(location = 1) out vec4 o_lit;
36
+ layout(location = 2) out vec4 o_glow;
37
+ #endif
30
38
  // aerial perspective (see haze()): colour·k + fog·b, premultiplied
31
39
  void main() {
32
- o = texture(u_tex, v_uv) * v_col;
40
+ vec4 c = texture(u_tex, v_uv) * v_col;
33
41
  // painted colours don't always hold the haze we assume: never darken more than 40%; an
34
42
  // additive quad (light, alpha 0) is never brightened by it
35
- o.rgb = clamp(o.rgb * v_haze.x + u_fog * v_haze.y * o.a, o.rgb * 0.6, o.a > 0.0 ? vec3(o.a) : o.rgb);
43
+ c.rgb = clamp(c.rgb * v_haze.x + u_fog * v_haze.y * c.a, c.rgb * 0.6, c.a > 0.0 ? vec3(c.a) : c.rgb);
44
+ #ifdef LIT
45
+ if (v_col.a > 0.0) { o = c; o_lit = vec4(v_lit.x * c.a, v_lit.x * v_lit.y * c.a, 0.0, c.a); o_glow = vec4(0.0, 0.0, 0.0, c.a); }
46
+ else { o = vec4(0.0); o_lit = vec4(0.0); o_glow = vec4(c.rgb, 0.0); }
47
+ #else
48
+ o = c;
49
+ #endif
50
+ }`;
51
+ // the same, cut by a mask (an element's `clip`): what other elements painted this frame, in a
52
+ // target as big as the canvas — seen inside it (u_inside 1) or outside it (0)
53
+ const CFS = FS.replace('uniform vec3 u_fog;', 'uniform vec3 u_fog;\n uniform sampler2D u_mask;\n uniform highp vec2 u_canvas;\n uniform float u_inside;')
54
+ .replace(/}\s*$/, ' float m = texture(u_mask, gl_FragCoord.xy / u_canvas).a, k = mix(1.0 - m, m, u_inside);\n o *= k;\n #ifdef LIT\n o_lit *= k; o_glow *= k;\n #endif\n }');
55
+ // A reflecting plane's mirror pass (mirrors): what is above the plane drawn where its mirror image
56
+ // is seen, into three targets like a lit scene's — the colour; [height above the plane (eye heights),
57
+ // px from where it meets it] (v_lit in this pass, premultiplied); the glows. Below the plane: nothing.
58
+ // (Its lit variant is drawn: litOf.)
59
+ const MFS = FS.replace('o_lit = vec4(v_lit.x * c.a, v_lit.x * v_lit.y * c.a, 0.0, c.a);', 'o_lit = vec4(v_lit * c.a, 0.0, c.a);')
60
+ .replace('void main() {', 'void main() {\n if (v_lit.y < 0.0) discard;');
61
+ // ... laid on the plane (drawn as its cells): only where it is painted (u_tex), u_strength of it,
62
+ // fading out over u_fade above the plane, blurred the more the farther above it (u_blur per px from
63
+ // where it meets the plane: the mirror's mip levels). The height of what is mirrored where nothing
64
+ // is: the average around.
65
+ const RFS = `#version 300 es
66
+ precision highp float;
67
+ in vec2 v_uv;
68
+ uniform sampler2D u_tex, u_mc, u_mh, u_mg;
69
+ uniform vec2 u_canvas;
70
+ uniform float u_strength, u_fade, u_blur;
71
+ layout(location = 0) out vec4 o;
72
+ #ifdef LIT
73
+ layout(location = 1) out vec4 o_lit;
74
+ layout(location = 2) out vec4 o_glow;
75
+ #endif
76
+ void main() {
77
+ vec2 q = gl_FragCoord.xy / u_canvas;
78
+ vec4 hc = textureLod(u_mh, q, 0.0), hb = textureLod(u_mh, q, 4.0);
79
+ vec2 hd = hc.a > 0.5 ? hc.rg / hc.a : hb.rg / max(hb.a, 1e-4);
80
+ float lod = min(5.0, log2(max(1.0, u_blur * hd.y * 0.5)));
81
+ vec2 d = exp2(lod) / u_canvas;
82
+ vec4 c = vec4(0.0); vec3 gw = vec3(0.0);
83
+ for (int i = 0; i < 4; i++) {
84
+ vec2 p = q + d * vec2(i == 0 || i == 3 ? -1.0 : 1.0, i < 2 ? -1.0 : 1.0);
85
+ c += textureLod(u_mc, p, lod); gw += textureLod(u_mg, p, lod).rgb;
86
+ }
87
+ float f = u_fade > 0.0 ? 1.0 - smoothstep(0.0, u_fade, hd.x) : 1.0, k = 0.25 * u_strength * f * texture(u_tex, v_uv).a;
88
+ #ifdef LIT
89
+ o = c * k; o_lit = vec4(0.0); o_glow = vec4(gw * k, 0.0);
90
+ #else
91
+ o = vec4((c.rgb + gw) * k, c.a * k);
92
+ #endif
93
+ }`;
94
+ // A plane's pool of light (`lit`: the scene's point lights where the scene is not lit), drawn as its
95
+ // cells, added: its painting (u_tex) lit that much more by each light by its distance — a point of it
96
+ // in the world from its uv (the plane: u_pc its centre, u_pa its size and turn)
97
+ const LFS = `#version 300 es
98
+ precision highp float;
99
+ in vec2 v_uv; in vec2 v_haze;
100
+ uniform sampler2D u_tex;
101
+ uniform vec3 u_pc;
102
+ uniform vec4 u_pa;
103
+ uniform int u_n;
104
+ uniform vec4 u_lp[8];
105
+ uniform vec3 u_lc[8];
106
+ layout(location = 0) out vec4 o;
107
+ #ifdef LIT
108
+ layout(location = 1) out vec4 o_lit;
109
+ layout(location = 2) out vec4 o_glow;
110
+ #endif
111
+ void main() {
112
+ vec4 c = texture(u_tex, v_uv);
113
+ float u = (v_uv.x - 0.5) * u_pa.x, v = (v_uv.y - 0.5) * u_pa.y;
114
+ vec3 P = vec3(u_pc.x + u * u_pa.z + v * u_pa.w, u_pc.y, u_pc.z - v * u_pa.z + u * u_pa.w), L = vec3(0.0);
115
+ for (int i = 0; i < 8; i++) {
116
+ if (i >= u_n) break;
117
+ float x = length(u_lp[i].xyz - P) / u_lp[i].w;
118
+ if (x < 1.0) L += u_lc[i] * (1.0 - x * x) * (1.0 - x * x);
119
+ }
120
+ o = vec4((c.rgb + 0.12 * c.a) * L * v_haze.x, 0.0);
121
+ #ifdef LIT
122
+ o_lit = vec4(0.0); o_glow = vec4(0.0);
123
+ #endif
36
124
  }`;
37
125
  // full-screen passes: a triangle pair in pixel coordinates
38
126
  const QVS = `#version 300 es
@@ -45,7 +133,11 @@ const MooodGL = (() => {
45
133
  uniform vec2 u_res, u_canvas, u_off; // the frame, the canvas, where the frame starts in it
46
134
  uniform float u_wy, u_k, u_t, u_speed, u_u;
47
135
  uniform vec3 u_col;
48
- out vec4 o;
136
+ layout(location = 0) out vec4 o;
137
+ #ifdef LIT
138
+ layout(location = 1) out vec4 o_lit;
139
+ layout(location = 2) out vec4 o_glow;
140
+ #endif
49
141
  void main() {
50
142
  float px = gl_FragCoord.x - u_off.x, py = u_canvas.y - gl_FragCoord.y - u_off.y;
51
143
  float depth = u_res.y - u_wy, d = clamp((py - u_wy) / depth, 0.0, 1.0);
@@ -55,29 +147,73 @@ const MooodGL = (() => {
55
147
  vec3 c = mix(refl, u_col, mix(0.35, 0.8, d));
56
148
  c += vec3(0.06) * (1.0 - step(u_u, py - u_wy)); // thin bright line on the horizon
57
149
  o = vec4(c, 1.0);
150
+ #ifdef LIT
151
+ // a row h below the horizon is about 1/h eye heights away
152
+ o_lit = vec4(1.0, clamp(u_res.y / max(1.0, py - u_wy), 0.3, 150.0), 0.0, 1.0);
153
+ o_glow = vec4(0.0);
154
+ #endif
58
155
  }`;
59
- // Final pass: soft-light tint, vignette, per-pixel film grain (overlay), fresh every frame.
156
+ // Final pass: soft-light tint, vignette, per-pixel film grain (overlay), fresh every frame; the
157
+ // scene's brightness and saturation (its look, engine.js: lookOf). A lit scene: first the light
158
+ // laid on the colour — the ambient, and each point source by how far the painted thing under the
159
+ // pixel is from it (a source behind it, farther along the gaze, lights it little: a figure before
160
+ // a lamp is a silhouette, a wall behind the lamp is lit) — then the glows.
161
+ const LIGHTS = 8;
60
162
  const PFS = `#version 300 es
61
163
  precision highp float;
62
164
  uniform sampler2D u_scene;
63
165
  uniform vec2 u_res, u_at, u_canvas; // the frame's size, where it starts in the canvas (window px), the canvas
64
166
  uniform vec3 u_tint;
65
- uniform float u_tintA, u_vig, u_grain, u_seed, u_fade;
167
+ uniform float u_tintA, u_vig, u_grain, u_seed, u_fade, u_bright, u_sat;
66
168
  uniform vec3 u_fadeCol;
169
+ #ifdef LIT
170
+ uniform sampler2D u_lit, u_glow;
171
+ uniform vec3 u_amb;
172
+ uniform int u_n;
173
+ uniform vec4 u_lp[${LIGHTS}]; // a point source: where (eye heights), and its reach
174
+ uniform vec3 u_lc[${LIGHTS}]; // its colour × strength
175
+ uniform vec4 u_cam; // the camera: x, z, height, yaw
176
+ uniform vec2 u_view; // the gaze lowered (screen heights), the horizon
177
+ #endif
67
178
  out vec4 o;
68
179
  float hash(vec2 p) { vec3 q = fract(vec3(p.xyx) * 0.1031); q += dot(q, q.yzx + 33.33); return fract((q.x + q.y) * q.z); }
69
180
  vec3 softLight(vec3 b, vec3 s) {
70
181
  vec3 d = mix(((16.0 * b - 12.0) * b + 4.0) * b, sqrt(b), step(0.25, b));
71
182
  return mix(b - (1.0 - 2.0 * s) * b * (1.0 - b), b + (2.0 * s - 1.0) * (d - b), step(0.5, s));
72
183
  }
184
+ #ifdef LIT
185
+ // the light on the painted colour c at frame pixel px (y up), its depth D along the gaze
186
+ vec3 lit(vec3 c, vec2 px, float D) {
187
+ float xe = (px.x - u_res.x * 0.5) / u_res.y * D, ye = (u_res.y - px.y) / u_res.y;
188
+ float cy = cos(u_cam.w), sy = sin(u_cam.w);
189
+ vec3 P = vec3(u_cam.x + xe * cy + D * sy, u_cam.z - (ye - u_view.y + u_view.x) * D, u_cam.y - xe * sy + D * cy);
190
+ vec3 L = u_amb;
191
+ for (int i = 0; i < ${LIGHTS}; i++) {
192
+ if (i >= u_n) break;
193
+ vec3 d = u_lp[i].xyz - P;
194
+ float x = length(d) / u_lp[i].w;
195
+ if (x >= 1.0) continue;
196
+ float f = (1.0 - x * x) * (1.0 - x * x);
197
+ float Dl = (u_lp[i].x - u_cam.x) * sy + (u_lp[i].z - u_cam.y) * cy;
198
+ L += u_lc[i] * f * mix(1.0, 0.3, smoothstep(0.02, 0.3, Dl - D));
199
+ }
200
+ return c * L;
201
+ }
202
+ #endif
73
203
  // Around the frame the scene goes on as painted; where nothing is painted (alpha 0), the colour at
74
204
  // the frame's nearest edge carries on. Out of the frame it dims gently toward the canvas's edges.
75
205
  void main() {
76
206
  vec2 q = gl_FragCoord.xy, px = q - u_at;
77
207
  vec4 s = texture(u_scene, q / u_canvas);
78
208
  vec3 edge = texture(u_scene, clamp(q, u_at + 0.5, u_at + u_res - 0.5) / u_canvas).rgb;
209
+ #ifdef LIT
210
+ vec4 l = texture(u_lit, q / u_canvas);
211
+ if (l.r > 0.002) s.rgb = mix(s.rgb, lit(s.rgb, px, l.g / l.r), min(1.0, l.r / max(s.a, 0.002)));
212
+ s.rgb += texture(u_glow, q / u_canvas).rgb;
213
+ #endif
79
214
  vec3 c = s.rgb + (1.0 - s.a) * edge;
80
215
  c = mix(c, softLight(c, u_tint), u_tintA);
216
+ c = mix(vec3(dot(c, vec3(0.299, 0.587, 0.114))), c, u_sat) * u_bright;
81
217
  float r0 = min(u_res.x, u_res.y) * 0.35, r1 = length(u_res) * 0.6;
82
218
  c *= 1.0 - u_vig * clamp((length(px - u_res * 0.5) - r0) / (r1 - r0), 0.0, 1.0);
83
219
  vec2 out_ = max(max(-px, px - u_res), 0.0);
@@ -86,6 +222,32 @@ const MooodGL = (() => {
86
222
  vec3 ov = mix(2.0 * c * n, 1.0 - 2.0 * (1.0 - c) * (1.0 - n), step(0.5, c));
87
223
  o = vec4(mix(mix(c, ov, u_grain), u_fadeCol, u_fade), 1.0);
88
224
  }`;
225
+ // A shadow: the caster's painting, black, laid where it falls (on a floor, on a wall) — through
226
+ // the alpha of what it falls on (u_mask; outside it, nothing), as dark as v_a.
227
+ const SVS = `#version 300 es
228
+ in vec2 a_pos; in vec2 a_uv; in vec2 a_uv2; in float a_a; in vec2 a_blur;
229
+ uniform vec2 u_canvas, u_off;
230
+ out vec2 v_uv; out vec2 v_uv2; out float v_a; out vec2 v_blur;
231
+ void main() {
232
+ v_uv = a_uv; v_uv2 = a_uv2; v_a = a_a; v_blur = a_blur;
233
+ vec2 p = a_pos + u_off;
234
+ gl_Position = vec4(p.x / u_canvas.x * 2.0 - 1.0, 1.0 - p.y / u_canvas.y * 2.0, 0.0, 1.0);
235
+ }`;
236
+ const SFS = `#version 300 es
237
+ precision mediump float;
238
+ in vec2 v_uv; in vec2 v_uv2; in float v_a; in vec2 v_blur;
239
+ uniform sampler2D u_tex, u_mask;
240
+ out vec4 o;
241
+ void main() {
242
+ float inside = step(0.0, v_uv2.x) * step(v_uv2.x, 1.0) * step(0.0, v_uv2.y) * step(v_uv2.y, 1.0);
243
+ // the caster's alpha over a disc as wide as the penumbra (a spiral of taps)
244
+ float a = 0.0;
245
+ for (int i = 0; i < 16; i++) {
246
+ float f = (float(i) + 0.5) / 16.0, t = float(i) * 2.39996;
247
+ a += texture(u_tex, v_uv + vec2(cos(t), sin(t)) * sqrt(f) * v_blur).a;
248
+ }
249
+ o = vec4(0.0, 0.0, 0.0, a / 16.0 * texture(u_mask, v_uv2).a * inside * v_a);
250
+ }`;
89
251
 
90
252
  function program(gl, vs, fs) {
91
253
  const p = gl.createProgram();
@@ -100,11 +262,17 @@ const MooodGL = (() => {
100
262
  gl.bindAttribLocation(p, 1, 'a_uv');
101
263
  gl.bindAttribLocation(p, 2, 'a_col');
102
264
  gl.bindAttribLocation(p, 3, 'a_haze');
265
+ gl.bindAttribLocation(p, 4, 'a_lit');
266
+ gl.bindAttribLocation(p, 2, 'a_uv2');
267
+ gl.bindAttribLocation(p, 3, 'a_a');
268
+ gl.bindAttribLocation(p, 4, 'a_blur');
103
269
  gl.linkProgram(p);
104
270
  if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
105
271
  return p;
106
272
  }
107
273
  const uniforms = (gl, p, names) => Object.fromEntries(names.map(n => [n, gl.getUniformLocation(p, 'u_' + n)]));
274
+ // a shader's lit variant (LIT defined)
275
+ const litOf = src => src.replace('#version 300 es', '#version 300 es\n #define LIT');
108
276
 
109
277
  // small 2D helpers for sprite textures
110
278
  function paint(w, h, fn) { const c = I.layer(w, h); fn(c.getContext('2d'), c.width, c.height); return c; }
@@ -134,10 +302,30 @@ const MooodGL = (() => {
134
302
  function create(canvas) {
135
303
  const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false, premultipliedAlpha: true, preserveDrawingBuffer: false });
136
304
  if (!gl) return null;
137
- const prog = program(gl, VS, FS), wprog = program(gl, QVS, WFS), pprog = program(gl, QVS, PFS);
138
- const su = uniforms(gl, prog, ['canvas', 'off', 'tex', 'fog']);
139
- const wu = uniforms(gl, wprog, ['res', 'canvas', 'off', 'tex', 'wy', 'k', 't', 'speed', 'u', 'col']);
140
- const pu = uniforms(gl, pprog, ['res', 'at', 'canvas', 'off', 'scene', 'tint', 'tintA', 'vig', 'grain', 'seed', 'fade', 'fadeCol']);
305
+ // the programs, plain and lit (made when a scene is first lit): { prog, cprog (cut by a mask:
306
+ // clip), wprog, pprog } and their uniforms; P: the current frame's
307
+ const programs = lit => {
308
+ const v = lit ? litOf : s => s, prog = program(gl, VS, v(FS)), cprog = program(gl, VS, v(CFS)), wprog = program(gl, QVS, v(WFS)), pprog = program(gl, QVS, v(PFS));
309
+ const rprog = program(gl, VS, v(RFS)), lprog = program(gl, VS, v(LFS));
310
+ return {
311
+ prog, cprog, wprog, pprog, rprog, lprog,
312
+ ru: uniforms(gl, rprog, ['canvas', 'off', 'tex', 'fog', 'mc', 'mh', 'mg', 'strength', 'fade', 'blur']),
313
+ lu: uniforms(gl, lprog, ['canvas', 'off', 'tex', 'fog', 'pc', 'pa', 'n', 'lp', 'lc']),
314
+ su: uniforms(gl, prog, ['canvas', 'off', 'tex', 'fog']),
315
+ cu: uniforms(gl, cprog, ['canvas', 'off', 'tex', 'fog', 'mask', 'inside']),
316
+ wu: uniforms(gl, wprog, ['res', 'canvas', 'off', 'tex', 'wy', 'k', 't', 'speed', 'u', 'col']),
317
+ pu: uniforms(gl, pprog, ['res', 'at', 'canvas', 'off', 'scene', 'tint', 'tintA', 'vig', 'grain', 'seed', 'fade', 'fadeCol', 'bright', 'sat', 'lit', 'glow', 'amb', 'n', 'lp', 'lc', 'cam', 'view']),
318
+ };
319
+ };
320
+ const PLAIN = programs(false);
321
+ let LITP = null, P = PLAIN;
322
+ // a lit scene needs a target it can write depths to (16-bit floats: either extension lets it)
323
+ const canLight = !!(gl.getExtension('EXT_color_buffer_float') || gl.getExtension('EXT_color_buffer_half_float'));
324
+ const sprog = program(gl, SVS, SFS), shu = uniforms(gl, sprog, ['canvas', 'off', 'tex', 'mask']);
325
+ // the mirror pass's program (made when a scene first has a reflecting plane: it needs the depths'
326
+ // targets too)
327
+ let MIRP = null;
328
+ const mirrorProgram = () => { const prog = program(gl, VS, litOf(MFS)); return { prog, su: uniforms(gl, prog, ['canvas', 'off', 'tex', 'fog']) }; };
141
329
 
142
330
  const data = new Float32Array(QUADS * 4 * STRIDE);
143
331
  const vao = gl.createVertexArray();
@@ -153,6 +341,19 @@ const MooodGL = (() => {
153
341
  gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 2, gl.FLOAT, false, STRIDE * 4, 8);
154
342
  gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 4, gl.FLOAT, false, STRIDE * 4, 16);
155
343
  gl.enableVertexAttribArray(3); gl.vertexAttribPointer(3, 2, gl.FLOAT, false, STRIDE * 4, 32);
344
+ gl.enableVertexAttribArray(4); gl.vertexAttribPointer(4, 2, gl.FLOAT, false, STRIDE * 4, 40);
345
+ // shadows: x y, u v (the caster), u v (what it falls on), alpha, blur (u v) — as triangles
346
+ const SH_STRIDE = 9, SH_MAX = 6 * 4096;
347
+ const svao = gl.createVertexArray();
348
+ gl.bindVertexArray(svao);
349
+ const svbo = gl.createBuffer();
350
+ gl.bindBuffer(gl.ARRAY_BUFFER, svbo);
351
+ gl.bufferData(gl.ARRAY_BUFFER, SH_MAX * SH_STRIDE * 4, gl.DYNAMIC_DRAW);
352
+ gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, SH_STRIDE * 4, 0);
353
+ gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 2, gl.FLOAT, false, SH_STRIDE * 4, 8);
354
+ gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 2, gl.FLOAT, false, SH_STRIDE * 4, 16);
355
+ gl.enableVertexAttribArray(3); gl.vertexAttribPointer(3, 1, gl.FLOAT, false, SH_STRIDE * 4, 24);
356
+ gl.enableVertexAttribArray(4); gl.vertexAttribPointer(4, 2, gl.FLOAT, false, SH_STRIDE * 4, 28);
156
357
  const qvao = gl.createVertexArray();
157
358
  gl.bindVertexArray(qvao);
158
359
  const qvbo = gl.createBuffer();
@@ -188,7 +389,49 @@ const MooodGL = (() => {
188
389
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
189
390
  return { tex, fbo };
190
391
  }
191
- const dropTarget = r => { gl.deleteTexture(r.tex); gl.deleteFramebuffer(r.fbo); };
392
+ // a lit scene's target: the colour, what is lit and how far (16-bit floats), the glows — one
393
+ // framebuffer drawing to all three
394
+ function litTarget(w, h) {
395
+ const fbo = gl.createFramebuffer(), texs = [];
396
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
397
+ [gl.RGBA8, gl.RGBA16F, gl.RGBA8].forEach((format, i) => {
398
+ const tex = gl.createTexture();
399
+ gl.bindTexture(gl.TEXTURE_2D, tex);
400
+ gl.texStorage2D(gl.TEXTURE_2D, 1, format, w, h);
401
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, i === 1 ? gl.NEAREST : gl.LINEAR);
402
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, i === 1 ? gl.NEAREST : gl.LINEAR);
403
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
404
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
405
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, tex, 0);
406
+ texs.push(tex);
407
+ });
408
+ gl.drawBuffers(ALL_BUFFERS);
409
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
410
+ return { tex: texs[0], lit: texs[1], glow: texs[2], fbo };
411
+ }
412
+ // a reflecting plane's mirror (MFS): the colour, [height, px] (16-bit floats), the glows — at half
413
+ // the canvas's size, with MIRROR_LEVELS mip levels its blur is read from
414
+ const MIRROR_LEVELS = 6;
415
+ function mirrorTarget(w, h) {
416
+ const fbo = gl.createFramebuffer(), texs = [];
417
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
418
+ [gl.RGBA8, gl.RGBA16F, gl.RGBA8].forEach((format, i) => {
419
+ const tex = gl.createTexture();
420
+ gl.bindTexture(gl.TEXTURE_2D, tex);
421
+ gl.texStorage2D(gl.TEXTURE_2D, MIRROR_LEVELS, format, w, h);
422
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
423
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
424
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
425
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
426
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, tex, 0);
427
+ texs.push(tex);
428
+ });
429
+ gl.drawBuffers(ALL_BUFFERS);
430
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
431
+ return { tex: texs[0], lit: texs[1], glow: texs[2], fbo, w, h };
432
+ }
433
+ const ALL_BUFFERS = [gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1, gl.COLOR_ATTACHMENT2], COLOUR_ONLY = [gl.COLOR_ATTACHMENT0, gl.NONE, gl.NONE];
434
+ const dropTarget = r => { for (const t of [r.tex, r.lit, r.glow]) if (t) gl.deleteTexture(t); gl.deleteFramebuffer(r.fbo); };
192
435
 
193
436
  // textures shared by every scene
194
437
  const T = {
@@ -206,32 +449,61 @@ const MooodGL = (() => {
206
449
  // OX, OY: where the frame starts in the canvas, CW × CH the canvas (px); what is seen is the
207
450
  // canvas — in the frame's pixels from (VX0, VY0) to (VX1, VY1)
208
451
  let n = 0, curTex = null, curAdd = false, W = 1, H = 1, SW = 1, ROUND = false;
452
+ // what the quads drawn now are cut by (an element's `clip`): { tex: the mask, inside }, or null
453
+ let CLIP = null;
209
454
  let OX = 0, OY = 0, CW = 1, CH = 1, VX0 = 0, VY0 = 0, VX1 = 1, VY1 = 1;
210
455
  // Aerial perspective: air between you and a thing tints it toward the horizon's sky
211
456
  // colour, h(D) = 1 − e^(−D/HAZE). The scene is painted as seen from the start, so its
212
457
  // colours already hold the haze of distance Z; walking to distance D we take that out and
213
458
  // put the new amount in: c·k + fog·b, k = (1−h(D))/(1−h(Z)), b = h(D) − h(Z)·k.
214
- // HAZE in eye heights: 220 = clear air (~40% haze at 200 m); spec.light.haze 0…1 → 600…25
459
+ // HAZE in eye heights: 220 = clear air (~40% haze at 200 m); spec.light.haze 0…1 → 600…25. The
460
+ // painting holds the scene's own haze (HAZE0, spec.light.haze); its look now (engine.js:
461
+ // lookOf) may thicken or thin the air (HAZE): what the painting holds is taken out, what the air
462
+ // holds now put in — in the starting view too.
215
463
  const NOHAZE = [1, 0];
216
- let HAZE = 220;
464
+ let HAZE = 220, HAZE0 = 220;
217
465
  let HZ = NOHAZE, FOG = [0.5, 0.5, 0.5];
218
466
  const hz = D => 1 - Math.exp(-Math.max(0, D) / HAZE);
219
467
  // painted for distance Z, seen from D away
220
468
  const haze = (Z, D) => {
221
- const h0 = Math.min(0.85, hz(Z)), h1 = hz(D), k = Math.min(3, (1 - h1) / (1 - h0));
469
+ const h0 = Math.min(0.85, 1 - Math.exp(-Math.max(0, Z) / HAZE0)), h1 = hz(D), k = Math.min(3, (1 - h1) / (1 - h0));
222
470
  return [k, h1 - h0 * k];
223
471
  };
472
+ // A lit scene: is what is drawn now lit by the scene's light, and how far along the gaze it is
473
+ // (eye heights) — [lit 0|1, depth]; the sky, the glows and inserts are not
474
+ const UNLIT = [0, 0];
475
+ let LT = UNLIT;
476
+ // The mirror pass (mirrors): MIR — the plane it mirrors, its level seen A + B/D down the frame D away
477
+ // along the gaze; MD — how far what is drawn now is (a vertex given none). A vertex at y, D away, goes
478
+ // where its mirror image is seen; MH, MP: its height above the plane (eye heights) and px from
479
+ // where it meets it — what the mirror fades and blurs by (MFS, RFS)
480
+ let MIR = null, MD = Infinity, MH = 0, MP = 0;
481
+ const mirrorY = (y, D) => {
482
+ const g = MIR.A + MIR.B / D;
483
+ MP = g - y; MH = isFinite(D) ? Math.max(-1e4, Math.min(1e4, MP * D / H)) : Math.sign(MP) * 1e4;
484
+ return g + MP;
485
+ };
486
+ // a pass over a plane's cells with a program of its own (RFS, LFS): { prog, u, set(u), add }
487
+ let PASS = null;
224
488
  function flush() {
225
489
  if (!n) return;
226
- gl.useProgram(prog);
490
+ const u = PASS ? PASS.u : CLIP ? P.cu : P.su;
491
+ gl.useProgram(PASS ? PASS.prog : CLIP ? P.cprog : P.prog);
227
492
  gl.bindVertexArray(vao);
228
- gl.uniform2f(su.canvas, CW, CH);
229
- gl.uniform2f(su.off, OX, OY);
230
- gl.uniform3f(su.fog, ...FOG);
493
+ gl.uniform2f(u.canvas, CW, CH);
494
+ gl.uniform2f(u.off, OX, OY);
495
+ gl.uniform3f(u.fog, ...FOG);
496
+ if (CLIP) {
497
+ gl.activeTexture(gl.TEXTURE1);
498
+ gl.bindTexture(gl.TEXTURE_2D, CLIP.tex);
499
+ gl.uniform1i(u.mask, 1);
500
+ gl.uniform1f(u.inside, CLIP.inside ? 1 : 0);
501
+ }
502
+ if (PASS) PASS.set(u);
231
503
  gl.activeTexture(gl.TEXTURE0);
232
504
  gl.bindTexture(gl.TEXTURE_2D, curTex);
233
- gl.uniform1i(su.tex, 0);
234
- if (curAdd) gl.blendFunc(gl.ONE, gl.ONE); else gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
505
+ gl.uniform1i(u.tex, 0);
506
+ if (curAdd || PASS?.add) gl.blendFunc(gl.ONE, gl.ONE); else gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
235
507
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
236
508
  gl.bufferSubData(gl.ARRAY_BUFFER, 0, data, 0, n * 4 * STRIDE);
237
509
  gl.drawElements(gl.TRIANGLES, n * 6, gl.UNSIGNED_SHORT, 0);
@@ -240,40 +512,91 @@ const MooodGL = (() => {
240
512
  // one textured quad: the rect (x, y, w, h) transformed by m, uv rect, tint, alpha.
241
513
  // Colours are premultiplied; additive quads get alpha 0 so they only add light.
242
514
  const UV = [0, 0, 1, 1];
243
- function rect(tex, m, x, y, w, h, c = WHITE, a = 1, add = false, uv = UV) {
244
- if (a <= 0) return;
515
+ function rect(tex, m, x, y, w, h, c = WHITE, a = 1, add = false, uv = UV, a1 = a) {
516
+ if (a <= 0 && a1 <= 0) return;
245
517
  if (tex !== curTex || add !== curAdd || n >= QUADS) { flush(); curTex = tex; curAdd = add; }
246
- const r = c[0] * a, g = c[1] * a, b = c[2] * a, al = add ? 0 : a;
247
518
  const ax = m[0] * w, ay = m[1] * w, bx = m[2] * h, by = m[3] * h;
248
519
  const ox = m[0] * x + m[2] * y + m[4], oy = m[1] * x + m[3] * y + m[5];
249
520
  let k = n * 4 * STRIDE;
250
- const v = (px, py, u, vv) => { data[k] = px; data[k + 1] = py; data[k + 2] = u; data[k + 3] = vv; data[k + 4] = r; data[k + 5] = g; data[k + 6] = b; data[k + 7] = al; data[k + 8] = HZ[0]; data[k + 9] = HZ[1]; k += STRIDE; };
251
- v(ox, oy, uv[0], uv[1]);
252
- v(ox + ax, oy + ay, uv[2], uv[1]);
253
- v(ox + ax + bx, oy + ay + by, uv[2], uv[3]);
254
- v(ox + bx, oy + by, uv[0], uv[3]);
521
+ // (a1: the alpha along its bottom edge, y + h — a reflection fading away)
522
+ const v = (px, py, u, vv, f) => { if (MIR) py = mirrorY(py, MD); data[k] = px; data[k + 1] = py; data[k + 2] = u; data[k + 3] = vv; data[k + 4] = c[0] * f; data[k + 5] = c[1] * f; data[k + 6] = c[2] * f; data[k + 7] = add ? 0 : f; data[k + 8] = HZ[0]; data[k + 9] = HZ[1]; data[k + 10] = MIR ? MH : LT[0]; data[k + 11] = MIR ? MP : LT[1]; k += STRIDE; };
523
+ v(ox, oy, uv[0], uv[1], a);
524
+ v(ox + ax, oy + ay, uv[2], uv[1], a);
525
+ v(ox + ax + bx, oy + ay + by, uv[2], uv[3], a1);
526
+ v(ox + bx, oy + by, uv[0], uv[3], a1);
255
527
  n++;
256
528
  }
257
- // a textured quad with four free corners p = [x0,y0, x1,y1, x2,y2, x3,y3] (clockwise from top-left)
258
- function quad(tex, p, uv, c = WHITE, a = 1) {
529
+ // a textured quad with four free corners p = [x0,y0, x1,y1, x2,y2, x3,y3] (clockwise from top-left);
530
+ // d: their depths (a lit scene: a surface going away), else the quad's (LT)
531
+ function quad(tex, p, uv, d = null) {
259
532
  if (tex !== curTex || curAdd || n >= QUADS) { flush(); curTex = tex; curAdd = false; }
260
- const r = c[0] * a, g = c[1] * a, b = c[2] * a;
261
533
  let k = n * 4 * STRIDE;
262
- const v = (i, u, vv) => { data[k] = p[i]; data[k + 1] = p[i + 1]; data[k + 2] = u; data[k + 3] = vv; data[k + 4] = r; data[k + 5] = g; data[k + 6] = b; data[k + 7] = a; data[k + 8] = HZ[0]; data[k + 9] = HZ[1]; k += STRIDE; };
534
+ const v = (i, u, vv) => { const y = MIR ? mirrorY(p[i + 1], d ? d[i >> 1] : MD) : p[i + 1]; data[k] = p[i]; data[k + 1] = y; data[k + 2] = u; data[k + 3] = vv; data[k + 4] = 1; data[k + 5] = 1; data[k + 6] = 1; data[k + 7] = 1; data[k + 8] = HZ[0]; data[k + 9] = HZ[1]; data[k + 10] = MIR ? MH : LT[0]; data[k + 11] = MIR ? MP : d ? d[i >> 1] : LT[1]; k += STRIDE; };
263
535
  v(0, uv[0], uv[1]); v(2, uv[2], uv[1]); v(4, uv[2], uv[3]); v(6, uv[0], uv[3]);
264
536
  n++;
265
537
  }
266
- // a textured quad with four free corners, each [x, y, u, v]
538
+ // a textured quad with four free corners, each [x, y, u, v, depth?]
267
539
  function quadAt(tex, A, B, C_, D) {
268
540
  if (tex !== curTex || curAdd || n >= QUADS) { flush(); curTex = tex; curAdd = false; }
269
541
  let k = n * 4 * STRIDE;
270
542
  for (const q of [A, B, C_, D]) {
271
- data[k] = q[0]; data[k + 1] = q[1]; data[k + 2] = q[2]; data[k + 3] = q[3];
272
- data[k + 4] = 1; data[k + 5] = 1; data[k + 6] = 1; data[k + 7] = 1; data[k + 8] = HZ[0]; data[k + 9] = HZ[1];
543
+ data[k] = q[0]; data[k + 1] = MIR ? mirrorY(q[1], q[4] ?? MD) : q[1]; data[k + 2] = q[2]; data[k + 3] = q[3];
544
+ data[k + 4] = 1; data[k + 5] = 1; data[k + 6] = 1; data[k + 7] = 1; data[k + 8] = HZ[0]; data[k + 9] = HZ[1]; data[k + 10] = MIR ? MH : LT[0]; data[k + 11] = MIR ? MP : q[4] ?? LT[1];
273
545
  k += STRIDE;
274
546
  }
275
547
  n++;
276
548
  }
549
+ // a convex polygon of points [x, y, u, v] (frame px, its texture's uv) as a fan, alpha a
550
+ function fan(tex, pts, a) {
551
+ if (tex !== curTex || curAdd || n + pts.length >= QUADS) { flush(); curTex = tex; curAdd = false; }
552
+ for (let i = 1; i + 1 < pts.length; i++) {
553
+ let k = n * 4 * STRIDE;
554
+ for (const q of [pts[0], pts[i], pts[i + 1], pts[i + 1]]) {
555
+ data[k] = q[0]; data[k + 1] = MIR ? mirrorY(q[1], MD) : q[1]; data[k + 2] = q[2]; data[k + 3] = q[3];
556
+ data[k + 4] = a; data[k + 5] = a; data[k + 6] = a; data[k + 7] = a; data[k + 8] = HZ[0]; data[k + 9] = HZ[1]; data[k + 10] = MIR ? MH : LT[0]; data[k + 11] = MIR ? MP : LT[1];
557
+ k += STRIDE;
558
+ }
559
+ n++;
560
+ }
561
+ }
562
+ // the part of a convex polygon where f (linear in x, y) is ≥ 0, its points' uv carried along
563
+ function keep(pts, f) {
564
+ const out = [];
565
+ for (let i = 0; i < pts.length; i++) {
566
+ const a = pts[i], b = pts[(i + 1) % pts.length], fa = f(a), fb = f(b);
567
+ if (fa >= 0) out.push(a);
568
+ if ((fa >= 0) !== (fb >= 0)) { const k = fa / (fa - fb); out.push(a.map((v, j) => v + (b[j] - v) * k)); }
569
+ }
570
+ return out;
571
+ }
572
+ // a textured rect (x, y, w, h: transformed by m, its uv) seen only above the line [[x, y], …]
573
+ // (frame px, x growing): what the land in front of it leaves seen
574
+ function rectAbove(tex, m, x, y, w, h, uv, a, line) {
575
+ const corners = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]].map(([px, py]) => [...apply(m, px, py), uv[0] + (px - x) / w * (uv[2] - uv[0]), uv[1] + (py - y) / h * (uv[3] - uv[1])]);
576
+ for (let j = 0; j + 1 < line.length; j++) {
577
+ const [xa, ya] = line[j], [xb, yb] = line[j + 1], k = (yb - ya) / ((xb - xa) || 1);
578
+ const poly = keep(keep(keep(corners, p => p[0] - xa), p => xb - p[0]), p => ya + k * (p[0] - xa) - p[1]);
579
+ if (poly.length >= 3) fan(tex, poly, a);
580
+ }
581
+ }
582
+ // the land in front (LAND, this frame's — engine.js landLine): for something drawn as the rect r
583
+ // through m, D away along the gaze, the line it is seen above — null: all of it seen, false: none
584
+ let LAND = null;
585
+ function landOver(m, r, D) {
586
+ if (!LAND) return null;
587
+ const xs = [], ys = [];
588
+ for (const [px, py] of [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]]) { const q = apply(m, px, py); xs.push(q[0]); ys.push(q[1]); }
589
+ const line = LAND.at(D, Math.min(...xs), Math.max(...xs), Math.max(...ys));
590
+ if (!line) return null;
591
+ return line.every(([, y]) => y <= Math.min(...ys)) ? false : line;
592
+ }
593
+ // is the point (x, y) (frame px) under the line?
594
+ const under = (line, x, y) => {
595
+ const j = line.findIndex(([lx]) => lx >= x);
596
+ if (j <= 0) return j === 0 ? y > line[0][1] : y > line.at(-1)[1];
597
+ const [xa, ya] = line[j - 1], [xb, yb] = line[j];
598
+ return y > ya + (yb - ya) * (x - xa) / ((xb - xa) || 1);
599
+ };
277
600
  function sprite(tex, m, cx, cy, w, h, rot, c, a, add) {
278
601
  rect(tex, rot ? mul(m, around(cx, cy, rot, 1, 1)) : m, cx - w / 2, cy - h / 2, w, h, c, a, add);
279
602
  }
@@ -398,7 +721,10 @@ const MooodGL = (() => {
398
721
  function build(spec, w, h, prev, out) {
399
722
  if (prev) {
400
723
  for (const t of prev.textures) gl.deleteTexture(t);
401
- dropTarget(prev.scene); dropTarget(prev.copy);
724
+ dropTarget(prev.plain); dropTarget(prev.copy);
725
+ if (prev.litScene) dropTarget(prev.litScene);
726
+ if (prev.mask) dropTarget(prev.mask);
727
+ for (const ob of prev.objects) if (ob.mirrorT) dropTarget(ob.mirrorT);
402
728
  for (const l of prev.layers) if (l.tiles) for (const t of l.tiles.values()) freeTile(t);
403
729
  }
404
730
  W = w; H = h; SW = I.STAGE * h; ROUND = I.camSetup(spec).round;
@@ -407,8 +733,7 @@ const MooodGL = (() => {
407
733
  const u = H / 900, seed = I.hash(JSON.stringify(spec.layers || []) + (spec.title || '')), rnd = I.seeded(seed);
408
734
  const x0 = -I.EXT * SW, span = (1 + 2 * I.EXT) * SW, dz = I.depths(spec);
409
735
  YH = dz.yh;
410
- FOG = col(spec.light?.hazeColor || spec.sky?.bottom || '#808080');
411
- HAZE = typeof spec.light?.haze === 'number' ? 600 * Math.pow(25 / 600, I.clamp(spec.light.haze, 0, 1)) : 220;
736
+ HAZE0 = typeof spec.light?.haze === 'number' ? I.hazeReach(spec.light.haze) : 220;
412
737
 
413
738
  const sky = spec.sky ? tex(paint(4, 256, (g) => { const r = g.createLinearGradient(0, 0, 0, 256); r.addColorStop(0, spec.sky.top); r.addColorStop(0.55, spec.sky.middle); r.addColorStop(1, spec.sky.bottom); g.fillStyle = r; g.fillRect(0, 0, 4, 256); })) : null;
414
739
  const starCount = Math.round((spec.stars?.density ?? 0) * span * H / 2500 / (u * u));
@@ -450,7 +775,8 @@ const MooodGL = (() => {
450
775
  const lseed = seed + i * 101, st = I.staticLayer(L, SW, H, lseed);
451
776
  return { L, Z, seed: lseed, tex: tex(st.canvas), x0: st.x0, region: { x: st.x0, y: 0, w: span, h: I.DEEP * H }, tops: st.tops, solids: st.solids, stripW: st.stripW, windows: st.windows, tiles: new Map() };
452
777
  });
453
- const objects = (spec.objects || []).map(o0 => {
778
+ const parted = []; // [an element painting parts, the parts it painted]
779
+ const objects = (spec.objects || []).filter(o0 => !o0.insert).map(o0 => {
454
780
  if (o0.plane) return planeOf(o0, dz, tex);
455
781
  // looking around, the sky is the full circle, 2π stage heights wide (engine.js)
456
782
  const n = dz.now(o0), o = { ...n.o, _sw: ROUND && o0.sky ? I.TAU * H : SW }, Z = n.Z;
@@ -468,30 +794,163 @@ const MooodGL = (() => {
468
794
  // a custom element whose code reads the time is repainted live every frame (one reading the
469
795
  // scene's state, when the state changes)
470
796
  const { bx, by, bw, bh } = ROUND && o.sky ? { bx: 0, by: -0.35 * H, bw: I.TAU * H, bh: 1.6 * H } : I.objectBox(o, SW, H);
471
- const r0 = paintObject(o, bx, by, bw, bh, Math.min(1, MAX_SIDE / bw, MAX_SIDE / bh), 0), frames = [tex(r0.canvas)], lights = r0.lights;
797
+ const whole = { x: bx, y: by, w: bw, h: bh };
798
+ const r0 = paintObject(o, bx, by, bw, bh, Math.min(1, MAX_SIDE / bw, MAX_SIDE / bh), 0, null, whole), frames = [tex(r0.canvas)], lights = r0.lights;
472
799
  const live = (o.kind === 'custom' || !o.kind) ? liveOf(o, o.frames, tex) : null;
473
- return {
474
- o, Z, x, y, s, bx, by, bw, bh, tex: frames[0], frames, period: o.period || 2, detail: null,
475
- p: ROUND && !o.sky ? n.at : null,
476
- live, ground: !!o.ground, from: o0.place ? o0 : null,
800
+ const ob = {
801
+ o, Z, x, y, s, bx, by, bw, bh, tex: frames[0], frames, pad: [(r0.region.w - bw) / 2 / bw, (r0.region.h - bh) / 2 / bh], period: o.period || 2, detail: null, cut: r0.cut,
802
+ p: ROUND && !o.sky ? n.at : null, at: n.at,
803
+ live, ground: !!o.ground, from: o0.place ? o0 : null, spec: o0, unseen: !!o0.unseen, parts: null,
477
804
  reflect: o.reflect ?? (o.kind === 'boat' || o.kind === 'bridge'),
478
- lights: lights.map(l => ({ x: l.x, y: l.y, r: l.r, c: col(l.col), a: l.a, dot: l.dot, fire: o.kind === 'campfire', flicker: l.flicker })),
805
+ lights: lights.map(l => ({ x: l.x, y: l.y, r: l.r, c: col(l.col), a: l.a, dot: l.dot, fire: o.kind === 'campfire', flicker: l.flicker, id: l.id })),
479
806
  };
807
+ if (r0.parts.length) { ob.parts = []; parted.push([ob, r0.parts]); }
808
+ return ob;
809
+ });
810
+ // inserts (engine.js): over all of it, fixed to the frame — repainted while open as the state
811
+ // changes (the iris their key opens), and every frame if their painting reads the time
812
+ const inserts = (spec.objects || []).filter(o0 => o0.insert).map(o0 => {
813
+ const o = { ...o0, _yh: dz.yh };
814
+ return { o, ...I.objectBox(o, SW, H), x: 0, y: 0, frames: [], period: o.period || 2, live: { canvas: I.layer(1, 1), tex: tex(I.layer(1, 1)), stateOnly: true, frames: 0, lights: [] } };
480
815
  });
481
816
  // particles fall over all that is seen: in the canvas's pixels
482
817
  const particles = (spec.particles || []).map(p => ({ p, c: col(p.color || '#ffffff'), list: I.makeParticles(p, CW, CH, rnd) }));
483
818
  // draw far to near, layers and objects together (a house behind a hill is hidden by it)
484
- const order = [...layers.map(l => ({ l, Z: l.Z })), ...objects.map(ob => ({ ob, Z: ob.Z }))].sort((a, b) => (b.Z - a.Z) || 0);
485
- return { W, H, SW, u, x0, span, yh: dz.yh, dz, lim: I.limits(spec, W, H), sky, stars, celestials, layers, objects, order, particles, textures, scene: target(CW, CH), copy: target(CW, CH), out };
819
+ // cut by others (clip): a mask target to paint them in
820
+ const ids = new Set(objects.map(ob => ob.o.id).filter(id => id != null));
821
+ for (const ob of objects) ob.clip = I.clipOf(ob.spec, ids);
822
+ const S = {
823
+ W, H, SW, u, x0, span, yh: dz.yh, dz, lim: I.limits(spec, W, H), sky, stars, celestials, layers, objects, inserts, particles, textures,
824
+ shadows: shadowsOf(spec, objects), lands: objects.filter(ob => ob.plane?.drape), plain: target(CW, CH), litScene: null, copy: target(CW, CH),
825
+ mask: objects.some(ob => ob.clip) ? target(CW, CH) : null, out, rules: I.orderRules(spec),
826
+ };
827
+ for (const [ob, recs] of parted) paintParts(S, ob, recs, null);
828
+ S.order = orderOf(S);
829
+ // the planes that reflect (a browser that can't write depths draws no mirror)
830
+ S.mirrors = canLight ? objects.filter(ob => ob.plane?.mirror && !ob.unseen).map(ob => ob.plane) : [];
831
+ return S;
832
+ }
833
+ // draw far to near, layers, objects and their parts together (a house behind a hill is hidden by
834
+ // it) — as the elements say (behind, inFront)
835
+ const idOf = it => (it.ob ? it.ob.o.id : it.part ? it.part.owner.o.id : null) ?? null;
836
+ function orderOf(S) {
837
+ const list = [...S.layers.map(l => ({ l, Z: l.Z })), ...S.objects.map(ob => ({ ob, Z: ob.Z }))];
838
+ for (const ob of S.objects) for (const part of ob.parts || []) list.push({ part, Z: part.Z });
839
+ return I.arrange(list.sort((a, b) => (b.Z - a.Z) || 0), S.rules, idOf);
840
+ }
841
+
842
+ // ── parts ────────────────────────────────────────────────────────────
843
+ // An element painting parts (e.part: a fence's posts, the people of a crowd) — each part stands
844
+ // at its own depth, in a texture of its own, drawn among the rest where it stands. A part: { owner,
845
+ // rec: {dx, dz, box, paint}, o: where it is now (depths().part), Z, x, y, s, its box on the stage,
846
+ // tex, lights: its glows as fractions of its box }. They are painted when their element is
847
+ // (repaint), as sharp as each is seen from cam (as from the start without one).
848
+ function placePart(S, part) {
849
+ const n = S.dz.part(part.owner.spec, part.rec.dx, part.rec.dz, part.rec.box), o = n.o;
850
+ Object.assign(part, { o, Z: n.Z, x: o.x * SW, y: o.y * H, s: Math.max(2, o.size * H), p: ROUND ? n.at : null, at: n.at }, I.objectBox(o, SW, H));
851
+ }
852
+ function paintParts(S, ob, recs, cam) {
853
+ const list = ob.parts;
854
+ for (const gone of list.splice(recs.length)) { gl.deleteTexture(gone.tex); S.textures.delete(gone.tex); }
855
+ recs.forEach((rec, i) => {
856
+ const part = list[i] ||= { owner: ob, canvas: I.layer(1, 1), tex: null, lights: [] };
857
+ part.rec = rec;
858
+ placePart(S, part);
859
+ const r = { x: part.bx, y: part.by, w: part.bw, h: part.bh }, seen = cam && matOf(part, cam);
860
+ const k = liveScale(r, seen ? Math.max(LIVE_AWAY, seen.m[0]) : 1), c = part.canvas, w = Math.max(1, Math.round(r.w * k)), h = Math.max(1, Math.round(r.h * k));
861
+ if (c.width !== w || c.height !== h) { c.width = w; c.height = h; }
862
+ const g = c.getContext('2d'), lights = [], [ax, ay, ppm] = I.anchorOf(part.o, SW, H);
863
+ g.setTransform(1, 0, 0, 1, 0, 0); g.clearRect(0, 0, w, h);
864
+ g.setTransform(k, 0, 0, k, -r.x * k, -r.y * k);
865
+ rec.paint(g, ax, ay, ppm, lights);
866
+ if (part.tex) { flush(); gl.bindTexture(gl.TEXTURE_2D, part.tex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, c); curTex = null; }
867
+ else { part.tex = texture(c); S.textures.add(part.tex); }
868
+ part.lights = lights.map(l => ({ u: (l.x - r.x) / r.w, v: (l.y - r.y) / r.h, r: l.r / r.w, c: col(l.col), a: l.a, flicker: l.flicker, id: l.id }));
869
+ });
870
+ }
871
+ // as each frame starts: the elements with parts painted if they are due (their parts with them),
872
+ // and their parts put where they are now
873
+ function prepareParts(S, cam, t) {
874
+ for (const ob of S.objects) {
875
+ if (!ob.parts) continue;
876
+ if (ob.live) {
877
+ for (const id of Array.isArray(ob.o.after) ? ob.o.after : []) { const other = BYID.get(id), oat = other?.live && matOf(other, cam); if (oat) textureOf(other, oat.m, t); }
878
+ // (itself out of sight — behind the eye — coarsely; its parts as they are seen: paintParts)
879
+ const at = matOf(ob, cam);
880
+ if (at) textureOf(ob, at.m, t); else unseen(ob, t);
881
+ }
882
+ for (const part of ob.parts) placePart(S, part);
883
+ }
884
+ }
885
+ function drawPartAt(part, cam, t) {
886
+ const at = matOf(part, cam);
887
+ if (!at) return;
888
+ MD = at.D;
889
+ const fade = I.nearFade(at.D), r = { x: part.bx, y: part.by, w: part.bw, h: part.bh };
890
+ const line = fade > 0 ? landOver(at.m, r, at.D) : false;
891
+ if (line === false) return;
892
+ part.shown = { tx: part.tex, r, m: at.m, frame: frameNo };
893
+ LT = part.owner.o.glows ? UNLIT : [1, at.D];
894
+ if (line) rectAbove(part.tex, at.m, r.x, r.y, r.w, r.h, UV, fade, line);
895
+ else rect(part.tex, at.m, r.x, r.y, r.w, r.h, WHITE, fade);
896
+ LT = UNLIT;
897
+ for (const lt of part.lights) {
898
+ const lx = r.x + lt.u * r.w, ly = r.y + lt.v * r.h;
899
+ if (line && under(line, ...apply(at.m, lx, ly))) continue;
900
+ const fl = lt.flicker != null ? 1 - lt.flicker * 0.5 * (0.5 + 0.5 * Math.sin(t * 9 + lx) * Math.sin(t * 5.3 + ly)) : 1;
901
+ sprite(T.glow, at.m, lx, ly, 2 * lt.r * r.w, 2 * lt.r * r.w, 0, lt.c, lt.a * 0.6 * fl * fade, true);
902
+ }
903
+ }
904
+
905
+ // ── cut by others ────────────────────────────────────────────────────
906
+ // An item of the order drawn — an element or a part of one — unless it is unseen (only a shape
907
+ // others are cut by); one with `clip` through the mask of what those it names paint now (list:
908
+ // the frame's order, where they are)
909
+ function drawItem(S, it, cam, t, list) {
910
+ const ob = it.ob || it.part.owner;
911
+ if (ob.unseen) return;
912
+ // (in a mirror: what is above its plane — not what is cut by others, the mask is the frame's)
913
+ if (MIR && (ob.clip || ob.ground || ob === MIR.ob || (ob.plane && ob.plane.Y <= MIR.Y + 0.01))) return;
914
+ if (ob.clip) {
915
+ maskOf(S, ob.clip.ids, cam, t, list);
916
+ CLIP = { tex: S.mask.tex, inside: ob.clip.inside };
917
+ }
918
+ if (it.part) drawPartAt(it.part, cam, t); else drawObjectAt(it.ob, cam, t);
919
+ if (ob.clip) { flush(); CLIP = null; }
920
+ }
921
+ // the elements `ids` (and their parts) painted in the mask target, as they are now (once a frame
922
+ // for all cut by the same ones)
923
+ function maskOf(S, ids, cam, t, list) {
924
+ const key = `${frameNo}${MIR ? ' mirrored' : ''}\n${ids.join('\n')}`;
925
+ if (S.maskKey === key) return;
926
+ S.maskKey = key;
927
+ flush();
928
+ gl.bindFramebuffer(gl.FRAMEBUFFER, S.mask.fbo);
929
+ gl.clearColor(0, 0, 0, 0);
930
+ gl.clear(gl.COLOR_BUFFER_BIT);
931
+ for (const it of list) {
932
+ if (!ids.includes(idOf(it)) || it.l) continue;
933
+ if (it.part) drawPartAt(it.part, cam, t); else drawObjectAt(it.ob, cam, t);
934
+ }
935
+ flush();
936
+ gl.bindFramebuffer(gl.FRAMEBUFFER, S.scene.fbo);
486
937
  }
487
938
 
488
939
  // Paints an object at t = 0 — the region (bx, by, bw, bh) of it — into a canvas at scale
489
940
  // k. Light rects and cones are baked in; glows stay sprites so they can flicker.
490
- function paintObject(o, bx, by, bw, bh, k, t = 0, into = null) {
491
- const lights = [];
941
+ // (and the parts it paints, e.part: each to be painted on its own — paintParts)
942
+ // With `whole` (its box) while the scene is checked (cutOf): painted with a margin, looked at
943
+ // there and the margin cleared. → { canvas, lights, parts, region: what the canvas holds, cut }
944
+ function paintObject(o, bx, by, bw, bh, k, t = 0, into = null, whole = null) {
945
+ // (the margin within the largest texture there may be)
946
+ if (whole && checked(o)) k = Math.min(k, (MAX_SIDE - 2 * CUT_MARGIN) / bw, (MAX_SIDE - 2 * CUT_MARGIN) / bh);
947
+ const m = whole && checked(o) ? CUT_MARGIN / k : 0;
948
+ if (m) { bx -= m; by -= m; bw += 2 * m; bh += 2 * m; }
949
+ const lights = [], parts = [];
492
950
  const draw = (g) => {
493
951
  g.setTransform(k, 0, 0, k, -bx * k, -by * k);
494
- I.drawObject(g, o, o._sw || SW, H, t, lights);
952
+ I.drawObject(g, o, o._sw || SW, H, t, lights, parts);
953
+ if (o.insert) I.iris(g, o, SW, H, I.opening(o), lights);
495
954
  for (const l of lights) {
496
955
  if (l.rect) { g.fillStyle = `rgba(${l.col},${Math.min(1, l.a * 2.2)})`; g.fillRect(...l.rect); }
497
956
  if (l.cone) {
@@ -512,7 +971,156 @@ const MooodGL = (() => {
512
971
  g.clearRect(0, 0, w, h);
513
972
  draw(g);
514
973
  } else canvas = paint(bw * k, bh * k, draw);
515
- return { canvas, lights };
974
+ const region = { x: bx, y: by, w: bw, h: bh };
975
+ return { canvas, lights, parts, region, cut: m ? cutOf(canvas, region, whole, m) : null };
976
+ }
977
+
978
+ // ── cut off by its box ───────────────────────────────────────────────
979
+ // An element is painted within its box: paint going on past the box's edge is cut there, a
980
+ // straight line wherever that edge comes into the frame. While the scene is checked (a render,
981
+ // ?debug: engine.js CHECK — reading pixels back is not free) a custom element is painted with a
982
+ // margin of CUT_MARGIN px round its box (a live one at most once a second): what it paints in the
983
+ // margin's outer pixel runs on past the box (a painting that ends at its edge does not), and is
984
+ // said once for each edge as it shows (cutShown), so the artist widens its "box"; the margin is
985
+ // cleared — the picture is as unchecked. [left, top, right]: not the bottom — an element's foot is
986
+ // mostly hidden by the ground and what stands nearer: said, it would mostly be noise.
987
+ const EDGES = ['left', 'top', 'right'], CUT_MARGIN = 2;
988
+ const checked = o => I.CHECK.on && (o.kind === 'custom' || !o.kind) && !o.sky && !o.ground && !o.plane && !o.insert;
989
+ // the canvas painted over region r with a margin m (r's units) round the part of it that is to be
990
+ // seen: the edges of that part that are its box's (whole) and are painted past → [left, top,
991
+ // right] or null; the margin cleared
992
+ function cutOf(canvas, r, whole, m) {
993
+ const g = canvas.getContext('2d'), w = canvas.width, h = canvas.height, M = Math.round(m * w / r.w);
994
+ const hit = (x, y, sw, sh) => { const d = g.getImageData(x, y, sw, sh).data; let n = 0; for (let i = 3; i < d.length; i += 4) if (d[i] > 40) n++; return n >= 3; };
995
+ const at = [Math.abs(r.x + m - whole.x) < 1, Math.abs(r.y + m - whole.y) < 1, Math.abs(r.x + r.w - m - whole.x - whole.w) < 1];
996
+ const cut = w > 2 * M + 8 && h > 2 * M + 8 ? [at[0] && hit(0, M, 1, h - 2 * M), at[1] && hit(M, 0, w - 2 * M, 1), at[2] && hit(w - 1, M, 1, h - 2 * M)] : [];
997
+ g.setTransform(1, 0, 0, 1, 0, 0);
998
+ g.clearRect(0, 0, M, h); g.clearRect(w - M, 0, M, h); g.clearRect(0, 0, w, M); g.clearRect(0, h - M, w, M);
999
+ return cut.some(Boolean) ? cut : null;
1000
+ }
1001
+ // the edges it is cut at that are in the frame now (m: its screen transform)
1002
+ function cutShown(ob, m) {
1003
+ const x0 = m[0] * ob.bx + m[4], x1 = x0 + m[0] * ob.bw, y0 = m[3] * ob.by + m[5], y1 = y0 + m[3] * ob.bh;
1004
+ const across = x1 > 0 && x0 < W, down = y1 > 0 && y0 < H;
1005
+ const seen = [down && x0 > 0 && x0 < W, across && y0 > 0 && y0 < H, down && x1 > 0 && x1 < W];
1006
+ ob.cut.forEach((c, i) => { if (c && seen[i]) I.warn(`${ob.o.name || ob.o.id}: its painting is cut off by its box at the ${EDGES[i]} edge, in the frame — widen its "box"`); });
1007
+ }
1008
+
1009
+ // ── shadows ──────────────────────────────────────────────────────────
1010
+ // A shadow (engine.js: shadowsOf) on the ground is drawn just before the element that casts it;
1011
+ // on a plane or an upright element, right after that one, only where it is painted (its alpha).
1012
+ // The caster's painting is laid on the receiver's plane as a grid of cells, each corner where the
1013
+ // ray from the light through it meets that plane (cells straddling another receiver met first are
1014
+ // split, then left to it), blurred as much as the light's size (`soft`) spreads its edge there:
1015
+ // sharp at the contact, softer away from it.
1016
+ const SH_GRID = [12, 20], SH_DEPTH = 3;
1017
+ // the scene's shadows (engine.js: shadowsOf) with their elements: { ob, onto: ['ground' | ob], … }
1018
+ function shadowsOf(spec, objects) {
1019
+ const byId = new Map(objects.filter(x => x.o.id != null).map(x => [String(x.o.id), x]));
1020
+ return I.shadowsOf(spec).map(sh => ({ ...sh, ob: byId.get(sh.id), onto: sh.onto.map(id => (id === 'ground' ? id : byId.get(id))) }));
1021
+ }
1022
+ // what a shadow falls on, as a plane of the world: a point q on it, its normal n, its texture
1023
+ // (what it is painted with now: the shadow shows only where that is) and where a world point is
1024
+ // on that texture — or null (a card that turns to face the eye)
1025
+ // (the ground: level where the caster stands, A — over the land, its height there; what isn't
1026
+ // drawn this frame takes none)
1027
+ function receiver(R, A) {
1028
+ if (R === 'ground') return { q: [0, I.groundAt(A.X * I.EYE, A.Z * I.EYE) / I.EYE, 0], n: [0, 1, 0], tex: T.white, uv: () => [0.5, 0.5] };
1029
+ if (!R.shown || R.shown.frame !== frameNo) return null;
1030
+ const { tx, r } = R.shown;
1031
+ if (R.plane) {
1032
+ const P = R.plane;
1033
+ return { q: [P.Xc, P.Y, P.Zc], n: [0, 1, 0], tex: tx, uv: S => { const dx = S[0] - P.Xc, dz = S[2] - P.Zc; return [(dx * P.cos + dz * P.sin + P.w / 2) / P.w, (dx * P.sin - dz * P.cos + P.d / 2) / P.d]; } };
1034
+ }
1035
+ const p = ROUND ? R.p : R.at;
1036
+ if (!p || (ROUND && typeof R.o.facing !== 'number')) return null;
1037
+ const k = H / p.Zp, f = ROUND ? R.o.facing * Math.PI / 180 : Math.PI, ux = -Math.cos(f), uz = Math.sin(f);
1038
+ return { q: [p.X, p.Y, p.Z], n: [uz, 0, -ux], tex: tx, uv: S => [(R.x + ((S[0] - p.X) * ux + (S[2] - p.Z) * uz) * k - r.x) / r.w, (R.y - (S[1] - p.Y) * k - r.y) / r.h] };
1039
+ }
1040
+ // what casts a shadow: an element, or a part of one (e.part) — { tx: its painting this frame, r:
1041
+ // the region of it that is, at: where it stands (depths().now), x, y: its anchor on the stage }
1042
+ function caster(it, cam, t) {
1043
+ if (it.part) { const p = it.part; return { tx: p.tex, r: { x: p.bx, y: p.by, w: p.bw, h: p.bh }, at: p.at, x: p.x, y: p.y }; }
1044
+ const ob = it.ob, at = matOf(ob, cam);
1045
+ if (!at) return null;
1046
+ const { tx, r } = textureOf(ob, at.m, t);
1047
+ return { tx, r, at: ob.at, x: ob.x, y: ob.y };
1048
+ }
1049
+ // the shadows falling on R now: on 'ground', the item's (an element or a part), before it is
1050
+ // drawn; on an element R, all that are cast on it — of the elements and their parts
1051
+ function castOn(S, R, it, cam, t) {
1052
+ if (MIR) return;
1053
+ const owner = it && (it.ob || it.part.owner);
1054
+ for (const sh of S.shadows) {
1055
+ if (!sh.onto.includes(R)) continue;
1056
+ // (each caster's on each receiver: its grid kept while nothing it depends on moves — slot)
1057
+ if (R === 'ground') { if (sh.ob === owner) drawShadow(S, sh, R, caster(it, cam, t), cam, `ground ${it.part ? owner.parts.indexOf(it.part) : -1}`); continue; }
1058
+ [{ ob: sh.ob }, ...(sh.ob.parts || []).map(part => ({ part }))].forEach((c, i) => drawShadow(S, sh, R, caster(c, cam, t), cam, `${R.o.id} ${i - 1}`));
1059
+ }
1060
+ }
1061
+ function drawShadow(S, sh, R, cs, cam, slot) {
1062
+ const src = cs && I.shadowLight(S.sources, sh, cs.at), pl = src && receiver(R, cs.at);
1063
+ if (!pl) return;
1064
+ const others = sh.onto.filter(x => x !== R).map(x => receiver(x, cs.at)).filter(Boolean);
1065
+ // the caster's painting this frame (painted first, if it is to be), and its points in the world
1066
+ const { tx, r } = cs, A = cs.at, k = A.Zp / H;
1067
+ const ax = ROUND ? Math.cos(cam.yaw || 0) : 1, az = ROUND ? -Math.sin(cam.yaw || 0) : 0;
1068
+ const world = (f, g) => { const u = (r.x + r.w * f - cs.x) * k; return [A.X + u * ax, A.Y - (r.y + r.h * g - cs.y) * k, A.Z + u * az]; };
1069
+ flush();
1070
+ gl.useProgram(sprog);
1071
+ gl.bindVertexArray(svao);
1072
+ gl.bindBuffer(gl.ARRAY_BUFFER, svbo);
1073
+ gl.uniform2f(shu.canvas, CW, CH);
1074
+ gl.uniform2f(shu.off, OX, OY);
1075
+ gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, pl.tex); gl.uniform1i(shu.mask, 1);
1076
+ gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tx); gl.uniform1i(shu.tex, 0);
1077
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
1078
+ if (S.lit) gl.drawBuffers(COLOUR_ONLY);
1079
+ // the grid laid as it was, while the caster, its light, what it falls on and the camera are where
1080
+ // they were (a still frame: none of it made again)
1081
+ const key = [!!MIR, W, H, A.X, A.Y, A.Z, r.x, r.y, r.w, r.h, ...(src.at || []), ...(src.dir || []), src.strength, sh.strength, S.ambient, S.lit,
1082
+ cam.x, cam.y, cam.z, cam.h, cam.yaw, ...pl.q, R.shown ? Object.values(R.shown.r).join() : '', ...others.flatMap(q => q.q), VX0, VY0, VX1, VY1].join();
1083
+ const grids = sh.grids ||= new Map();
1084
+ let verts = grids.get(slot)?.key === key ? grids.get(slot).verts : null;
1085
+ if (!verts) {
1086
+ const wk = r.w * k, hk = r.h * k, out = [];
1087
+ // a corner: [screen x, y, its u v on the caster, on the receiver, how dark, how blurred (u, v)] —
1088
+ // or null where its ray meets another receiver first, or none. The penumbra of a light `soft`
1089
+ // across: soft·(t − 1) on the receiver from a point (t: how much farther than the caster it is),
1090
+ // soft·t from far off — on the caster's painting that over the magnification
1091
+ const corner = (f, g) => {
1092
+ const C = world(f, g), h = I.rayHit(pl, C, src.at, src.dir);
1093
+ if (!h || others.some(q => { const o2 = I.rayHit(q, C, src.at, src.dir); return o2 && o2[1] < h[1]; })) return null;
1094
+ const p = I.project(cam, ...h[0], W, H, YH);
1095
+ if (!p) return null;
1096
+ const b = src.dir ? sh.soft * h[1] : sh.soft * (h[1] - 1) / h[1];
1097
+ return [p[0], p[1], f, g, ...pl.uv(h[0]), I.shadowAt(sh, src, h[0], S.ambient, S.lit), b / wk, b / hk];
1098
+ };
1099
+ const cell = (f0, g0, f1, g1, depth) => {
1100
+ const c = [corner(f0, g0), corner(f1, g0), corner(f1, g1), corner(f0, g1)], ok = c.filter(Boolean).length;
1101
+ if (ok === 0) return;
1102
+ if (ok < 4) {
1103
+ if (depth >= SH_DEPTH) return;
1104
+ const fm = (f0 + f1) / 2, gm = (g0 + g1) / 2;
1105
+ cell(f0, g0, fm, gm, depth + 1); cell(fm, g0, f1, gm, depth + 1); cell(f0, gm, fm, g1, depth + 1); cell(fm, gm, f1, g1, depth + 1);
1106
+ return;
1107
+ }
1108
+ if (Math.max(...c.map(q => q[0])) < VX0 || Math.min(...c.map(q => q[0])) > VX1 || Math.max(...c.map(q => q[1])) < VY0 || Math.min(...c.map(q => q[1])) > VY1) return;
1109
+ out.push(c[0], c[1], c[2], c[0], c[2], c[3]);
1110
+ };
1111
+ const [GC, GR] = SH_GRID;
1112
+ for (let gi = 0; gi < GR; gi++) for (let fi = 0; fi < GC; fi++) cell(fi / GC, gi / GR, (fi + 1) / GC, (gi + 1) / GR, 0);
1113
+ verts = new Float32Array(out.length * SH_STRIDE);
1114
+ out.forEach((q, i) => verts.set(q, i * SH_STRIDE));
1115
+ grids.set(slot, { key, verts });
1116
+ }
1117
+ for (let i = 0; i < verts.length; i += SH_MAX * SH_STRIDE) {
1118
+ const n = Math.min(SH_MAX * SH_STRIDE, verts.length - i);
1119
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, verts, i, n);
1120
+ gl.drawArrays(gl.TRIANGLES, 0, n / SH_STRIDE);
1121
+ }
1122
+ if (S.lit) gl.drawBuffers(ALL_BUFFERS);
1123
+ curTex = null;
516
1124
  }
517
1125
 
518
1126
  // ── sharp close-ups ──────────────────────────────────────────────────
@@ -549,9 +1157,9 @@ const MooodGL = (() => {
549
1157
  // objects: re-painted at the needed scale, only the part on screen (a tall tower
550
1158
  // close up would otherwise be a huge canvas)
551
1159
  for (const ob of [...S.objects].reverse()) {
552
- if (ob.o.kind === 'bird' || ob.live || ob.ground || ob.plane || ob.o.sky || (ROUND && typeof ob.o.facing === 'number')) continue;
1160
+ if (ob.o.kind === 'bird' || ob.live || ob.parts || ob.ground || ob.plane || ob.o.sky || (ROUND && typeof ob.o.facing === 'number')) continue;
553
1161
  const at = matOf(ob, c);
554
- if (!at || at.D < 0.3) continue;
1162
+ if (!at || at.D < 0.05) continue;
555
1163
  const k = Math.pow(LEVEL, levelFor(at.m[0]));
556
1164
  if (k <= 1) { if (ob.detail && c === cam) setDetail(S, ob, null); continue; }
557
1165
  const v = visible(at.m), box = { x: ob.bx, y: ob.by, w: ob.bw, h: ob.bh };
@@ -581,6 +1189,7 @@ const MooodGL = (() => {
581
1189
  }
582
1190
 
583
1191
  function drawWater(S, l, wy, t) {
1192
+ if (MIR) return;
584
1193
  flush();
585
1194
  if (wy >= VY1) return;
586
1195
  // copy what is drawn so far, then shade the water below the horizon from that copy
@@ -588,39 +1197,44 @@ const MooodGL = (() => {
588
1197
  gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, S.copy.fbo);
589
1198
  gl.blitFramebuffer(0, 0, CW, CH, 0, 0, CW, CH, gl.COLOR_BUFFER_BIT, gl.NEAREST);
590
1199
  gl.bindFramebuffer(gl.FRAMEBUFFER, S.scene.fbo);
591
- gl.useProgram(wprog);
1200
+ gl.useProgram(P.wprog);
592
1201
  gl.activeTexture(gl.TEXTURE0);
593
1202
  gl.bindTexture(gl.TEXTURE_2D, S.copy.tex);
594
- gl.uniform1i(wu.tex, 0);
595
- gl.uniform2f(wu.res, W, H);
596
- gl.uniform2f(wu.canvas, CW, CH);
597
- gl.uniform2f(wu.off, OX, OY);
598
- gl.uniform1f(wu.wy, Math.max(0, wy));
599
- gl.uniform1f(wu.k, Math.min(1.2, Math.max(0, wy) / (H - wy)));
600
- gl.uniform1f(wu.t, t);
601
- gl.uniform1f(wu.speed, 1 + Math.abs(l.L.drift ?? 0) * 2);
602
- gl.uniform1f(wu.u, S.u);
603
- gl.uniform3f(wu.col, ...l.c);
604
- band(wprog, Math.max(VY0, wy), VY1);
1203
+ gl.uniform1i(P.wu.tex, 0);
1204
+ gl.uniform2f(P.wu.res, W, H);
1205
+ gl.uniform2f(P.wu.canvas, CW, CH);
1206
+ gl.uniform2f(P.wu.off, OX, OY);
1207
+ gl.uniform1f(P.wu.wy, Math.max(0, wy));
1208
+ gl.uniform1f(P.wu.k, Math.min(1.2, Math.max(0, wy) / (H - wy)));
1209
+ gl.uniform1f(P.wu.t, t);
1210
+ gl.uniform1f(P.wu.speed, 1 + Math.abs(l.L.drift ?? 0) * 2);
1211
+ gl.uniform1f(P.wu.u, S.u);
1212
+ gl.uniform3f(P.wu.col, ...l.c);
1213
+ band(P.wprog, Math.max(VY0, wy), VY1);
605
1214
  }
606
1215
 
607
1216
  const wrapX = (x, S, w) => ((x % (S.span + w)) + S.span + w) % (S.span + w) + S.x0 - w;
608
1217
  const WINDOW = col('#ffd08a'), DOT = col('255,245,220');
609
1218
 
610
- // how finely a live element's region r is repainted: the scale it is seen at, up to ~4 Mpx
1219
+ // how finely a live element's region r is repainted: the scale it is seen at
611
1220
  const LIVE_AWAY = 0.05; // the scale a live element out of sight is painted at (for its lights)
612
- const liveScale = (r, seen) => Math.min(seen, MAX_SIDE / r.w, MAX_SIDE / r.h, Math.sqrt(4e6 / (r.w * r.h)));
1221
+ // (a budget of pixels a repaint takes: what is on screen is painted only where it is seen — the
1222
+ // screen and a margin at most — so as sharp as it is seen on a screen up to LIVE_BUDGET; a bigger
1223
+ // one, a little softer: a 4K screen's iGPU is not to repaint 20 million pixels a frame)
1224
+ const LIVE_BUDGET = 8e6;
1225
+ const liveScale = (r, seen) => Math.min(seen, MAX_SIDE / r.w, MAX_SIDE / r.h, Math.sqrt(Math.min(LIVE_BUDGET, Math.max(4e6, 1.3 * CW * CH)) / (r.w * r.h)));
613
1226
 
614
1227
  // A ground plane (water, fields, a road) is painted as seen from the start. Each row of it
615
1228
  // lies at a depth — a row h below the horizon is 1/h eye heights away — so as you walk, the
616
1229
  // rows are laid out again in perspective: drawn as thin strips, each with its own haze.
617
- function drawGround(ob, cam, t) {
1230
+ function drawGround(ob, cam, t, draw) {
618
1231
  let tx = ob.tex;
619
1232
  if (ob.live) {
620
1233
  const whole = { x: ob.bx, y: ob.by, w: ob.bw, h: ob.bh }, k = liveScale(whole, 1);
621
- if (stale(ob.live, k, whole)) repaint(ob, k, t, whole);
1234
+ if (stale(ob, k, whole)) repaint(ob, k, t, whole);
622
1235
  tx = ob.live.tex;
623
1236
  }
1237
+ if (!draw) return;
624
1238
  const yhH = YH * H, top = Math.max(ob.by, yhH + 0.5), bot = ob.by + ob.bh, N = 120, look = -cam.y * H;
625
1239
  const row = y => {
626
1240
  const Z0 = H / (y - yhH), D = Z0 - cam.z;
@@ -634,13 +1248,15 @@ const MooodGL = (() => {
634
1248
  if (!b) break;
635
1249
  const y0 = top + (bot - top) * ((i - 1) / N) ** 1.6, xl = ob.bx, xr = ob.bx + ob.bw;
636
1250
  if (b.y > VY0 && a.y < VY1) {
637
- HZ = haze(a.Z0, a.Z0 - cam.z);
638
- quad(tx, [a.s * xl + a.e, a.y, a.s * xr + a.e, a.y, b.s * xr + b.e, b.y, b.s * xl + b.e, b.y], [0, (y0 - ob.by) / ob.bh, 1, (y1 - ob.by) / ob.bh]);
1251
+ HZ = haze(a.Z0, a.Z0 - cam.z); LT = ob.o.glows ? UNLIT : [1, 0];
1252
+ const da = a.Z0 - cam.z, db = b.Z0 - cam.z;
1253
+ quad(tx, [a.s * xl + a.e, a.y, a.s * xr + a.e, a.y, b.s * xr + b.e, b.y, b.s * xl + b.e, b.y], [0, (y0 - ob.by) / ob.bh, 1, (y1 - ob.by) / ob.bh], [da, da, db, db]);
639
1254
  }
640
1255
  if (a.y > VY1) break;
641
1256
  a = b;
642
1257
  }
643
- HZ = NOHAZE;
1258
+ HZ = NOHAZE; LT = UNLIT;
1259
+ glowsOf(ob, ob.live?.r ? ob.live.lights : ob.lights, cam, t);
644
1260
  }
645
1261
 
646
1262
  // ── horizontal planes ────────────────────────────────────────────────
@@ -653,146 +1269,228 @@ const MooodGL = (() => {
653
1269
  // World units here are eye heights, as everywhere in the camera: a point (X, Y, Z) is seen
654
1270
  // at x = W/2 + H·(X − cam.x)/(Z − cam.z), y = yh·H + H·(1 − Y)/(Z − cam.z) − cam.y·H.
655
1271
  // Live elements: repainted every frame if their code read the time as it was painted; if it
656
- // read only the scene's state (e.state, e.was), when the state changed — or when seen at a scale
657
- // the old painting would blur. What it reads is looked at again at every repaint.
1272
+ // read only the scene's state (e.state, e.was), when a key it read changed (engine.js: moved) —
1273
+ // or when seen at a scale the old painting would blur. What it reads is looked at again at every
1274
+ // repaint.
1275
+ // One with `redraw` (engine.js: redrawOf) is repainted only when the keys it names change, whatever
1276
+ // else it reads; one reading the light falling on it (e.lights), when the light does.
1277
+ const timedOf = (o, frames) => o.redraw == null && (frames > 1 || !!I.READS.get(o)?.time);
658
1278
  function liveOf(o, frames, tex) {
659
- const read = I.READS.get(o) || {}, timed = frames > 1 || read.time;
660
- return timed || read.state ? { canvas: I.layer(1, 1), tex: tex(I.layer(1, 1)), stateOnly: !timed, frames, lights: [] } : null;
661
- }
662
- // stale: painted at another state, or at a scale the painting would blur, or not over all of `need`
663
- // (painted this frame already — an element that reads it got it painted first — it is not)
664
- function stale(live, k, need) {
665
- const f0 = live.k ? k / live.k : 0;
666
- if (live.frame === frameNo && f0 > 0.83 && f0 < 1.2 && live.r && covers(live.r, need)) return false;
1279
+ const read = I.READS.get(o) || {}, timed = timedOf(o, frames);
1280
+ return timed || read.state || read.lights || o.redraw != null ? { canvas: I.layer(1, 1), tex: tex(I.layer(1, 1)), stateOnly: !timed, frames, lights: [] } : null;
1281
+ }
1282
+ // stale: painted at another state (or light), or at a scale the painting would blur, or not over
1283
+ // all of `need` (painted this frame already — an element that reads it got it painted first — it is not)
1284
+ function stale(ob, k, need) {
1285
+ const live = ob.live, f = live.k ? k / live.k : 0, sharp = f > 0.83 && f < 1.2 && live.r && covers(live.r, need);
1286
+ if (live.frame === frameNo && sharp) return false;
667
1287
  if (!live.stateOnly) return true;
668
- const f = live.k ? k / live.k : 0;
669
- return live.ver !== I.STATE.ver || !(f > 0.83 && f < 1.2) || !live.r || !covers(live.r, need);
1288
+ if (!sharp) return true;
1289
+ if (ob.o.redraw != null) return live.snap !== I.redrawOf(ob.o);
1290
+ if (live.ver !== I.STATE.ver) { if (I.moved(live.read)) return true; live.ver = I.STATE.ver; }
1291
+ return live.readsLights && live.src !== I.SOURCES.ver;
670
1292
  }
671
- // repaints the region r of a live element (all of it, or what is on screen close up)
672
- function repaint(ob, k, t, r) {
673
- const p = paintObject(ob.o, r.x, r.y, r.w, r.h, k, t, ob.live.canvas);
674
- ob.live.r = r; ob.live.frame = frameNo;
1293
+ // repaints the region r of a live element (all of it, or what is on screen close up); `seen`:
1294
+ // painted to be seen — only then is it looked at for being cut off by its box
1295
+ function repaint(ob, k, t, r, seen = true) {
1296
+ // (looked at for being cut off by its box at most once a second, when it is to be seen and not
1297
+ // too coarse to tell: a few pixels across)
1298
+ const whole = { x: ob.bx, y: ob.by, w: ob.bw, h: ob.bh }, look = seen && !(t - ob.cutAt < 1) && r.w * k >= 16 && r.h * k >= 16 && checked(ob.o);
1299
+ const p = paintObject(ob.o, r.x, r.y, r.w, r.h, k, t, ob.live.canvas, look ? whole : null);
1300
+ if (look) { ob.cutAt = t; ob.cut = p.cut; }
1301
+ ob.live.r = p.region; ob.live.frame = frameNo;
1302
+ if (p.parts.length || ob.parts) { ob.parts ||= []; paintParts(SCENE, ob, p.parts, CAM); }
675
1303
  flush(); // quads already batched must be drawn with their own texture first
1304
+ const t0 = I.PERF.on ? I.clock() : 0;
676
1305
  gl.bindTexture(gl.TEXTURE_2D, ob.live.tex);
677
1306
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, p.canvas);
1307
+ if (I.PERF.on) I.PERF.add(ob.o, I.clock() - t0, true); // (its upload too)
678
1308
  curTex = null;
679
- ob.live.ver = I.STATE.ver; ob.live.k = k;
680
- ob.live.stateOnly = !(ob.live.frames > 1 || I.READS.get(ob.o)?.time);
681
- ob.live.lights = p.lights.map(l => ({ x: l.x, y: l.y, r: l.r, c: col(l.col), a: l.a, dot: l.dot, flicker: l.flicker }));
1309
+ ob.live.ver = I.STATE.ver; ob.live.k = k; ob.live.src = I.SOURCES.ver; ob.live.snap = I.redrawOf(ob.o); ob.live.read = I.READS.get(ob.o);
1310
+ ob.live.stateOnly = !timedOf(ob.o, ob.live.frames); ob.live.readsLights = !!ob.live.read?.lights;
1311
+ I.PERF.repainted(ob.o);
1312
+ ob.live.lights = p.lights.map(l => ({ x: l.x, y: l.y, r: l.r, c: col(l.col), a: l.a, dot: l.dot, flicker: l.flicker, id: l.id }));
682
1313
  }
683
1314
  const PLANE_TEX = 2048;
684
1315
  function planeOf(o0, dz, tex) {
685
- const E = I.EYE, w = Math.max(0.05, o0.width || 4), d = Math.max(0.05, o0.depth || 4);
686
- const at = dz.objectAt(o0), Zc = at.Z, Xc = at.X, rot = (o0.rotation || 0) * Math.PI / 180;
1316
+ const E = I.EYE, plane = I.planeOf(o0, dz), w = plane.w * E, d = plane.d * E, Zc = plane.Zc;
687
1317
  // painted as a flat (sky-like) element: code units are metres, 1 m = H px before scaling
688
- const o = { ...o0, sky: true, plane: true };
1318
+ const o = { ...o0, sky: true, plane: true, _plane: plane };
689
1319
  const k = PLANE_TEX / (Math.max(w, d) * H), bx = -w / 2 * H, by = -d / 2 * H, bw = w * H, bh = d * H;
690
1320
  const r0 = paintObject(o, bx, by, bw, bh, k, 0);
691
1321
  const live = liveOf(o, 0, tex);
692
- const c = Math.abs(Math.cos(rot)), s = Math.abs(Math.sin(rot));
1322
+ const c = Math.abs(plane.cos), s = Math.abs(plane.sin);
693
1323
  return {
694
- o, plane: { Xc, Y: (o0.elevation || 0) / E, Zc, w: w / E, d: d / E, cos: Math.cos(rot), sin: Math.sin(rot) },
695
- Z: Zc + (d * c + w * s) / 2 / E, bx, by, bw, bh, k, tex: tex(r0.canvas), frames: [], lights: [],
696
- live,
1324
+ o, plane, Z: Zc + (d * c + w * s) / 2 / E, bx, by, bw, bh, k, tex: tex(r0.canvas), frames: [],
1325
+ lights: r0.lights.map(l => ({ x: l.x, y: l.y, r: l.r, c: col(l.col), a: l.a, dot: l.dot, flicker: l.flicker, id: l.id })),
1326
+ live, spec: o0, unseen: !!o0.unseen,
697
1327
  };
698
1328
  }
699
- // A flat piece of the world drawn truly in perspective: world(f, g) → [X, Y, Z] (eye heights)
700
- // for the point (f, g) of its texture, both 0…1. Drawn as C × R cells; a cell close to the eye
701
- // (nearer than it is big) is split finer, and one partly behind the eye is cut at the near
702
- // plane, not dropped — a floor reaches right to your feet. hazeOf(D): the haze D away.
703
- const NEAR = 0.06;
704
- const eyeOf = (cam, X, Y, Z) => {
705
- const rx = X - cam.x, rz = Z - cam.z, c = Math.cos(cam.yaw || 0), s = Math.sin(cam.yaw || 0);
706
- return [rx * c - rz * s, (cam.h ?? 1) - Y, rx * s + rz * c];
707
- };
708
- function surface(cam, tex, world, C, R, hazeOf) {
709
- const cell = (f0, g0, f1, g1, depth) => {
710
- let poly = [[f0, g0], [f1, g0], [f1, g1], [f0, g1]].map(([f, g]) => ({ e: eyeOf(cam, ...world(f, g)), f, g }));
711
- const zs = poly.map(k => k.e[2]);
712
- if (Math.max(...zs) < NEAR) return;
713
- const size = Math.hypot(poly[0].e[0] - poly[2].e[0], poly[0].e[1] - poly[2].e[1], poly[0].e[2] - poly[2].e[2]);
714
- if (depth < 4 && Math.min(...zs) < size) {
715
- const fm = (f0 + f1) / 2, gm = (g0 + g1) / 2;
716
- cell(f0, g0, fm, gm, depth + 1); cell(fm, g0, f1, gm, depth + 1); cell(f0, gm, fm, g1, depth + 1); cell(fm, gm, f1, g1, depth + 1);
717
- return;
718
- }
719
- if (Math.min(...zs) < NEAR) {
720
- const cut = [];
721
- for (let i = 0; i < poly.length; i++) {
722
- const a = poly[i], b = poly[(i + 1) % poly.length], ain = a.e[2] >= NEAR, bin = b.e[2] >= NEAR;
723
- if (ain) cut.push(a);
724
- if (ain !== bin) {
725
- const k = (NEAR - a.e[2]) / (b.e[2] - a.e[2]);
726
- cut.push({ e: a.e.map((v, j) => v + (b.e[j] - v) * k), f: a.f + (b.f - a.f) * k, g: a.g + (b.g - a.g) * k });
727
- }
728
- }
729
- poly = cut;
730
- if (poly.length < 3) return;
731
- }
732
- const pt = poly.map(k => [W / 2 + H * k.e[0] / k.e[2], YH * H + H * k.e[1] / k.e[2] - cam.y * H, k.f, k.g]);
733
- const xs = pt.map(q => q[0]), ys = pt.map(q => q[1]);
734
- if (Math.max(...xs) < VX0 || Math.min(...xs) > VX1 || Math.max(...ys) < VY0 || Math.min(...ys) > VY1) return;
735
- HZ = hazeOf(poly.reduce((d, k) => d + k.e[2], 0) / poly.length);
1329
+ // A flat piece of the world drawn truly in perspective (engine.js: cellsOf — world(f, g) → [X, Y,
1330
+ // Z], C × R cells; or its cells made already: the land's, landCells), each cell with the haze of
1331
+ // its distance (hazeOf(D)), lit or not
1332
+ function surface(cam, tex, world, C, R, hazeOf, lit, made) {
1333
+ const quads = pt => {
1334
+ HZ = hazeOf(pt.reduce((d, q) => d + q[4], 0) / pt.length); LT = lit ? [1, 0] : UNLIT;
736
1335
  if (pt.length === 3) quadAt(tex, pt[0], pt[1], pt[2], pt[2]);
737
1336
  else quadAt(tex, pt[0], pt[1], pt[2], pt[3]);
738
1337
  if (pt.length === 5) quadAt(tex, pt[0], pt[3], pt[4], pt[4]);
739
1338
  };
740
- for (let i = 0; i < R; i++) for (let j = 0; j < C; j++) cell(j / C, i / R, (j + 1) / C, (i + 1) / R, 0);
741
- HZ = NOHAZE;
1339
+ if (made) made.forEach(quads); else I.cellsOf(cam, world, C, R, W, H, YH, [VX0, VY0, VX1, VY1], quads);
1340
+ HZ = NOHAZE; LT = UNLIT;
1341
+ }
1342
+ // the land this frame: its draped planes' cells, as they are drawn (drawPlane) and what stands
1343
+ // behind them is cut along (engine.js: landLine) — the view's and above it
1344
+ function landCells(S, cam) {
1345
+ for (const ob of S.lands) { ob.cells = []; I.cellsOf(cam, I.onPlane(ob.plane), ...I.PLANE_CELLS.webgl, W, H, YH, [VX0, -Infinity, VX1, VY1], pts => ob.cells.push(pts)); }
1346
+ }
1347
+ // the glows of a plane's or the ground's lights, where they are in the world
1348
+ function glowsOf(ob, lights, cam, t) {
1349
+ for (const lt of lights) {
1350
+ const q = lightPlace(ob, lt), p = q && I.project(cam, ...q.at, W, H, YH);
1351
+ if (!p) continue;
1352
+ const r = q.reach / 2 * H / p[2], fl = flickerOf(lt, 0, 0, t);
1353
+ MD = p[2];
1354
+ sprite(T.glow, ID, p[0], p[1], 2 * r, 2 * r, 0, lt.c, lt.a * 0.6 * fl, true);
1355
+ if (lt.dot) sprite(T.glow, ID, p[0], p[1], 6 * lt.dot * r / lt.r, 6 * lt.dot * r / lt.r, 0, DOT, 0.9, true);
1356
+ }
742
1357
  }
743
1358
 
744
- function drawPlane(ob, cam, t) {
1359
+ const planeHaze = D => { const h = hz(D); return [1 - h, h]; };
1360
+ // a pass over a plane's cells, as it is drawn, with a program of its own (mirrorPass, poolPass)
1361
+ function overPlane(ob, cam, tx, pass) {
1362
+ flush(); PASS = pass;
1363
+ surface(cam, tx, I.onPlane(ob.plane), ...I.PLANE_CELLS.webgl, planeHaze, false, ob.cells);
1364
+ flush(); PASS = null;
1365
+ }
1366
+ // Each reflecting plane in view: what is above it drawn as its mirror shows it (MIR) into the
1367
+ // plane's mirror target — at half size, only where the plane is seen (the scissor), its mip levels
1368
+ // made for the blur — before the frame: it is laid on the plane as the plane is drawn (mirrorPass).
1369
+ // Its own `reflect` copy is not drawn over it; what is cut by others (clip) is not mirrored.
1370
+ function mirrors(S, spec, cam, t, mSky) {
1371
+ for (const ob of S.objects) {
1372
+ ob.mirrored = null;
1373
+ const Pl = ob.plane;
1374
+ if (!S.mirrors.includes(Pl)) continue;
1375
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
1376
+ I.cellsOf(cam, I.onPlane(Pl), 4, 4, W, H, YH, [VX0, VY0, VX1, VY1], pts => { for (const q of pts) { x0 = Math.min(x0, q[0]); x1 = Math.max(x1, q[0]); y0 = Math.min(y0, q[1]); y1 = Math.max(y1, q[1]); } });
1377
+ x0 = Math.max(x0, VX0); x1 = Math.min(x1, VX1); y0 = Math.max(y0, VY0); y1 = Math.min(y1, VY1);
1378
+ if (!(x1 > x0 && y1 > y0)) continue;
1379
+ const R = ob.mirrorT ??= mirrorTarget(Math.ceil(CW / 2), Math.ceil(CH / 2)), keep = P;
1380
+ flush();
1381
+ gl.bindFramebuffer(gl.FRAMEBUFFER, R.fbo);
1382
+ gl.viewport(0, 0, R.w, R.h);
1383
+ gl.clearColor(0, 0, 0, 0);
1384
+ gl.clear(gl.COLOR_BUFFER_BIT);
1385
+ gl.enable(gl.SCISSOR_TEST);
1386
+ gl.scissor(Math.floor((x0 + OX) / 2) - 1, Math.floor((CH - y1 - OY) / 2) - 1, Math.ceil((x1 - x0) / 2) + 3, Math.ceil((y1 - y0) / 2) + 3);
1387
+ P = MIRP ??= mirrorProgram();
1388
+ MIR = { ob, Y: Pl.Y, A: (YH - cam.y) * H, B: H * ((cam.h ?? 1) - Pl.Y) }; MD = Infinity;
1389
+ if (S.sky) rect(S.sky, mSky, S.x0, -H, S.span, 3 * H, WHITE, 1, false, [0, -1, 1, 2]);
1390
+ if (ROUND) drawAround(S, cam, t); else drawAhead(S, spec, cam, t, mSky);
1391
+ flush();
1392
+ MIR = null; P = keep;
1393
+ gl.disable(gl.SCISSOR_TEST);
1394
+ for (const tex of [R.tex, R.lit, R.glow]) { gl.bindTexture(gl.TEXTURE_2D, tex); gl.generateMipmap(gl.TEXTURE_2D); }
1395
+ curTex = null;
1396
+ ob.mirrored = R;
1397
+ }
1398
+ }
1399
+ // the plane's mirror laid on it (RFS)
1400
+ function mirrorPass(ob) {
1401
+ const M = ob.plane.mirror, R = ob.mirrored;
1402
+ return {
1403
+ prog: P.rprog, u: P.ru, add: false, set: u => {
1404
+ [R.tex, R.lit, R.glow].forEach((tex, i) => { gl.activeTexture(gl.TEXTURE1 + i); gl.bindTexture(gl.TEXTURE_2D, tex); });
1405
+ gl.uniform1i(u.mc, 1); gl.uniform1i(u.mh, 2); gl.uniform1i(u.mg, 3);
1406
+ gl.uniform1f(u.strength, M.strength); gl.uniform1f(u.fade, M.fade); gl.uniform1f(u.blur, M.blur);
1407
+ },
1408
+ };
1409
+ }
1410
+ // the scene's point lights near a plane that is `lit`, laid on it as much as the scene is not lit
1411
+ // (LFS) — or null
1412
+ const POOL_LIGHTS = 8;
1413
+ function poolPass(ob, t) {
1414
+ const Pl = ob.plane, k = Pl.pool * (1 - SCENE.lit), half = Math.hypot(Pl.w, Pl.d) / 2;
1415
+ if (k <= 0) return null;
1416
+ const pts = SCENE.sources.filter(s => !s.dir && s.strength > 0.002 && s.at[1] >= Pl.Y - 0.03 && Math.hypot(s.at[0] - Pl.Xc, s.at[2] - Pl.Zc) - half < s.reach);
1417
+ if (!pts.length) return null;
1418
+ pts.sort((a, b) => b.strength * b.reach - a.strength * a.reach).length = Math.min(pts.length, POOL_LIGHTS);
1419
+ const lp = new Float32Array(4 * POOL_LIGHTS), lc = new Float32Array(3 * POOL_LIGHTS);
1420
+ pts.forEach((s, i) => {
1421
+ const f = s.sprite ? flickerOf(s.sprite.lt, s.sprite.x, s.sprite.y, t) : I.flickerNow(s, t);
1422
+ lp.set([...s.at, s.reach], 4 * i);
1423
+ lc.set(s.rgb.map(v => v / 255 * s.strength * f * k), 3 * i);
1424
+ });
1425
+ return {
1426
+ prog: P.lprog, u: P.lu, add: true, set: u => {
1427
+ gl.uniform3f(u.pc, Pl.Xc, Pl.Y, Pl.Zc); gl.uniform4f(u.pa, Pl.w, Pl.d, Pl.cos, Pl.sin);
1428
+ gl.uniform1i(u.n, pts.length); gl.uniform4fv(u.lp, lp); gl.uniform3fv(u.lc, lc);
1429
+ },
1430
+ };
1431
+ }
1432
+ function drawPlane(ob, cam, t, draw) {
745
1433
  let tx = ob.tex;
746
1434
  if (ob.live) {
747
1435
  const whole = { x: ob.bx, y: ob.by, w: ob.bw, h: ob.bh }, k = Math.min(ob.k, 1024 / Math.max(ob.bw, ob.bh));
748
- if (stale(ob.live, k, whole)) repaint(ob, k, t, whole);
1436
+ if (stale(ob, k, whole)) repaint(ob, k, t, whole);
749
1437
  tx = ob.live.tex;
750
1438
  }
751
- // local (u across, v toward the viewer, from its centre); painted without haze (seen from
752
- // above): all of it is the air at its own distance
753
- const P = ob.plane;
754
- surface(cam, tx, (f, g) => {
755
- const u = -P.w / 2 + P.w * f, v = -P.d / 2 + P.d * g;
756
- return [P.Xc + u * P.cos + v * P.sin, P.Y, P.Zc - v * P.cos + u * P.sin];
757
- }, 24, 48, D => { const h = hz(D); return [1 - h, h]; });
1439
+ if (!draw) return;
1440
+ // painted without haze (seen from above): all of it is the air at its own distance; draped
1441
+ // (spec.relief), it lies on the land
1442
+ surface(cam, tx, I.onPlane(ob.plane), ...I.PLANE_CELLS.webgl, planeHaze, !ob.o.glows, ob.cells);
1443
+ ob.shown = { tx, r: { x: ob.bx, y: ob.by, w: ob.bw, h: ob.bh }, frame: frameNo };
1444
+ if (ob.mirrored && !MIR) overPlane(ob, cam, tx, mirrorPass(ob));
1445
+ const pool = !MIR && ob.plane.pool && poolPass(ob, t);
1446
+ if (pool) overPlane(ob, cam, tx, pool);
1447
+ glowsOf(ob, ob.live?.r ? ob.live.lights : ob.lights, cam, t);
758
1448
  }
759
1449
 
1450
+ // A live element out of sight: painted all the same, coarsely, so its lights are where they are
1451
+ // now (a lantern carried off-screen still lights the edge it is behind) and what keeps a memory
1452
+ // of its own goes on — the elements it reads (`after`) first
1453
+ function unseen(ob, t) {
1454
+ for (const id of Array.isArray(ob.o.after) ? ob.o.after : []) { const other = BYID.get(id); if (other?.live) unseen(other, t); }
1455
+ const whole = { x: ob.bx, y: ob.by, w: ob.bw, h: ob.bh }, k = Math.min(LIVE_AWAY, liveScale(whole, 1));
1456
+ if (stale(ob, k, whole)) repaint(ob, k, t, whole, false);
1457
+ return { tx: ob.live.tex, r: ob.live.r, lights: ob.live.lights };
1458
+ }
760
1459
  // the texture an element is shown with now: its live painting (repainted when due), else its
761
1460
  // frame of the loop — the sharp close-up when that covers what is seen of it
762
1461
  function textureOf(ob, m, t) {
763
1462
  const n = ob.frames.length, fi = n > 1 ? Math.floor((((t / ob.period) % 1) + 1) % 1 * n) % n : 0, whole = { x: ob.bx, y: ob.by, w: ob.bw, h: ob.bh };
764
1463
  const seen = ob.live && m ? intersect(whole, visible(m)) : null;
765
1464
  if (ob.live && (!m || seen)) {
766
- // close up only what is on screen (and a margin, so a small move needn't repaint) is painted:
767
- // as sharp as it is seen
768
- const need = seen || whole;
769
- const r = seen ? intersect(whole, { x: seen.x - seen.w * 0.15, y: seen.y - seen.h * 0.15, w: seen.w * 1.3, h: seen.h * 1.3 }) : whole;
1465
+ // close up only what is on screen is painted, as sharp as it is seen — with a margin, wide for
1466
+ // one repainted only as the state changes (a small move of the camera needn't repaint it)
1467
+ const need = seen || whole, mg = ob.live.stateOnly ? 0.15 : 0.02;
1468
+ const r = seen ? intersect(whole, { x: seen.x - seen.w * mg, y: seen.y - seen.h * mg, w: seen.w * (1 + 2 * mg), h: seen.h * (1 + 2 * mg) }) : whole;
770
1469
  const k = liveScale(r, m ? m[0] : 1);
771
- if (stale(ob.live, k, need)) repaint(ob, k, t, r);
772
- return { tx: ob.live.tex, r: ob.live.r, lights: ob.live.lights };
773
- }
774
- if (ob.live) {
775
- // out of sight: painted all the same, coarsely, so its lights are where they are now (a
776
- // lantern carried off-screen still lights the edge it is behind)
777
- const k = Math.min(LIVE_AWAY, liveScale(whole, 1));
778
- if (stale(ob.live, k, whole)) repaint(ob, k, t, whole);
1470
+ if (stale(ob, k, need)) repaint(ob, k, t, r);
779
1471
  return { tx: ob.live.tex, r: ob.live.r, lights: ob.live.lights };
780
1472
  }
1473
+ if (ob.live) return unseen(ob, t);
781
1474
  const det = ob.detail, onScreen = det && m && intersect(whole, visible(m));
782
1475
  if (det && m && (!onScreen || covers(det.region, onScreen))) return { tx: det.frames[fi], r: det.region, lights: ob.lights };
783
- return { tx: ob.frames[fi], r: whole, lights: ob.lights };
1476
+ // (painted with a margin round its box, when the scene was checked: pad, fractions of it)
1477
+ const [px, py] = ob.pad;
1478
+ return { tx: ob.frames[fi], r: { x: ob.bx - px * ob.bw, y: ob.by - py * ob.bh, w: ob.bw * (1 + 2 * px), h: ob.bh * (1 + 2 * py) }, lights: ob.lights };
784
1479
  }
785
1480
 
786
- function drawObjectAt(ob, cam, t) {
787
- if (ob.plane) { drawPlane(ob, cam, t); return; }
788
- if (ob.ground) { drawGround(ob, cam, t); return; }
789
- if (ROUND && ob.o.sky) { drawSkyAround(ob, cam, t); return; }
790
- if (ROUND && typeof ob.o.facing === 'number') { drawCard(ob, cam, t); return; }
791
- const at = matOf(ob, cam);
792
- if (!at) return;
793
- const fade = I.clamp((at.D - 0.3) / 0.6, 0, 1);
794
- if (fade <= 0) return;
1481
+ // draw: false — only what drawing it paints is painted (a frame unseen, painted as when drawn: tick)
1482
+ function drawObjectAt(ob, cam, t, draw = true) {
1483
+ if (ob.plane) { drawPlane(ob, cam, t, draw); return; }
1484
+ if (ob.ground) { drawGround(ob, cam, t, draw); return; }
1485
+ if (ROUND && ob.o.sky) { drawSkyAround(ob, cam, t, draw); return; }
1486
+ if (ROUND && typeof ob.o.facing === 'number') { drawCard(ob, cam, t, draw); return; }
1487
+ // (out of sight — behind the eye, passed — a live element goes on all the same, painted coarsely,
1488
+ // as a frame unseen paints it: tick)
1489
+ const at = matOf(ob, cam), fade = at ? I.nearFade(at.D) : 0;
1490
+ if (fade <= 0) { if (ob.live) unseen(ob, t); return; }
1491
+ MD = at.D;
795
1492
  const { o, x, y, s } = ob;
1493
+ if (draw && ob.cut) cutShown(ob, at.m);
796
1494
  // a platformer's hero: moved and turned from where it is painted
797
1495
  const m = cam.hero && o.id === cam.hero.id ? mul(at.m, I.heroMatrix(cam.hero, x, s / (o.height || 2))) : at.m;
798
1496
  if (o.kind === 'bird') {
@@ -821,46 +1519,59 @@ const MooodGL = (() => {
821
1519
  const other = BYID.get(id), oat = other?.live && matOf(other, cam);
822
1520
  if (oat) textureOf(other, oat.m, t);
823
1521
  }
824
- const ma = a === ID ? m : mul(m, a), { tx, r, lights } = textureOf(ob, m, t);
1522
+ const { tx, r, lights } = textureOf(ob, m, t);
1523
+ if (!draw) return;
1524
+ const ma = a === ID ? m : mul(m, a);
1525
+ // the land in front of it (spec.relief) hides what is behind its rise
1526
+ const line = o.sky ? null : landOver(ma, r, at.D);
1527
+ if (line === false) return;
1528
+ ob.shown = { tx, r, m: ma, frame: frameNo };
1529
+ LT = o.sky || o.glows ? UNLIT : [1, at.D];
825
1530
  // on water: a mirrored, fainter copy about the water surface under it (for something
826
- // raised above the ground, e.g. sitting on a pier, that is below its base)
827
- if (ob.reflect) {
1531
+ // raised above the ground, e.g. sitting on a pier, that is below its base), fading away from it —
1532
+ // not over a reflecting plane: that mirrors it
1533
+ if (ob.reflect && !MIR && !I.overMirror(SCENE.mirrors, ob.at)) {
828
1534
  const gy = isFinite(ob.Z) ? Math.max(y, (YH + 1 / ob.Z) * H) : YH * H; // sky: about the horizon
829
- rect(tx, mul(ma, [1, 0, 0, -1, 0, 2 * gy]), r.x, r.y, r.w, r.h, WHITE, 0.3 * fade);
1535
+ rect(tx, mul(ma, [1, 0, 0, -1, 0, 2 * gy]), r.x, r.y, r.w, r.h, WHITE, 0.08 * fade, false, UV, 0.3 * fade);
830
1536
  }
831
- rect(tx, ma, r.x, r.y, r.w, r.h, WHITE, fade);
1537
+ if (line) rectAbove(tx, ma, r.x, r.y, r.w, r.h, UV, fade, line);
1538
+ else rect(tx, ma, r.x, r.y, r.w, r.h, WHITE, fade);
1539
+ LT = UNLIT;
832
1540
  for (const lt of lights) {
833
- const fl = lt.fire ? 0.8 + 0.2 * Math.sin(t * 11)
834
- : lt.flicker != null ? 1 - lt.flicker * 0.5 * (0.5 + 0.5 * Math.sin(t * 9 + lt.x) * Math.sin(t * 5.3 + lt.y))
835
- : 0.9 + 0.1 * Math.sin(t * 7 + x) * Math.sin(t * 3.1 + y);
836
1541
  const [lx, ly] = apply(a, lt.x, lt.y);
837
- sprite(T.glow, m, lx, ly, 2 * lt.r, 2 * lt.r, 0, lt.c, lt.a * 0.6 * fl * fade, true);
1542
+ if (line && under(line, ...apply(m, lx, ly))) continue;
1543
+ sprite(T.glow, m, lx, ly, 2 * lt.r, 2 * lt.r, 0, lt.c, lt.a * 0.6 * flickerOf(lt, x, y, t) * fade, true);
838
1544
  if (lt.dot) sprite(T.glow, m, lx, ly, 6 * lt.dot, 6 * lt.dot, 0, DOT, 0.9 * fade, true);
839
1545
  }
840
- }
1546
+ }
841
1547
 
842
1548
  // Looking around, an element with `facing` stands turned its own way (a wall, a door, a
843
1549
  // cupboard): its painting is a vertical card in the world — the painted point (px, py) is
844
1550
  // (px − x, py − y)/ppm metres from its anchor along the card and down — projected truly,
845
1551
  // as a grid of small cells like a plane, each with the haze of its own distance.
846
- function drawCard(ob, cam, t) {
1552
+ function drawCard(ob, cam, t, draw) {
847
1553
  const p = ob.p, f = ob.o.facing * Math.PI / 180, ppm = H / p.Zp; // painted px per eye height
848
1554
  const tx_ = -Math.cos(f), tz = Math.sin(f); // along the card, to the right of one facing it
849
1555
  const { tx, r, lights } = textureOf(ob, null, t);
1556
+ if (!draw) return;
1557
+ ob.shown = { tx, r, frame: frameNo };
850
1558
  const world = (px, py) => { const u = (px - ob.x) / ppm, v = (py - ob.y) / ppm; return [p.X + u * tx_, p.Y - v, p.Z + u * tz]; };
851
- surface(cam, tx, (f, g) => world(r.x + r.w * f, r.y + r.h * g), 16, 10, D => haze(p.Zp, D));
1559
+ surface(cam, tx, (f, g) => world(r.x + r.w * f, r.y + r.h * g), 16, 10, D => haze(p.Zp, D), !ob.o.glows);
852
1560
  const at = (px, py) => I.project(cam, ...world(px, py), W, H, YH);
853
1561
  for (const lt of lights) {
854
1562
  const q = at(lt.x, lt.y);
855
1563
  if (!q) continue;
1564
+ MD = q[2];
856
1565
  const k = p.Zp / q[2], fl = lt.flicker != null ? 1 - lt.flicker * 0.5 * (0.5 + 0.5 * Math.sin(t * 9 + lt.x) * Math.sin(t * 5.3 + lt.y)) : 1;
857
1566
  sprite(T.glow, ID, q[0], q[1], 2 * lt.r * k, 2 * lt.r * k, 0, lt.c, lt.a * 0.6 * fl, true);
858
1567
  }
859
1568
  }
860
1569
  // The sky all around: its painting is the full circle (2π stage heights, straight ahead in
861
1570
  // the middle); each narrow column of the frame shows the bearing it looks at.
862
- function drawSkyAround(ob, cam, t) {
1571
+ function drawSkyAround(ob, cam, t, draw) {
863
1572
  const { tx } = textureOf(ob, null, t), C = 48, top = ob.by - cam.y * H;
1573
+ if (!draw) return;
1574
+ MD = Infinity;
864
1575
  for (let i = 0; i < C; i++) {
865
1576
  const xa = VX0 + (VX1 - VX0) * i / C, xb = VX0 + (VX1 - VX0) * (i + 1) / C;
866
1577
  const ua = (cam.yaw + Math.atan((xa - W / 2) / H)) / I.TAU + 0.5, ub = (cam.yaw + Math.atan((xb - W / 2) / H)) / I.TAU + 0.5;
@@ -875,6 +1586,7 @@ const MooodGL = (() => {
875
1586
 
876
1587
  // An element that moves through the world (its `place`): put where the state has it now
877
1588
  // (depths().now) — its size, its depth, its haze and what it is drawn in front of or behind follow.
1589
+ // → whether anything moved
878
1590
  function placeMoving(S) {
879
1591
  let moved = false;
880
1592
  for (const ob of S.objects) {
@@ -882,22 +1594,101 @@ const MooodGL = (() => {
882
1594
  const n = S.dz.now(ob.from), key = `${n.o.x}|${n.o.y}|${n.o.size}`;
883
1595
  if (key === ob.placed) continue;
884
1596
  ob.placed = key; moved = true;
885
- const o = { ...n.o, _sw: ob.o._sw };
886
- Object.assign(ob, { o, Z: n.Z, x: o.x * SW, y: o.y * H, s: Math.max(2, o.size * H) }, I.objectBox(o, SW, H));
1597
+ const o = { ...n.o, _sw: ob.o._sw }, was = { bx: ob.bx, by: ob.by, bw: ob.bw };
1598
+ Object.assign(ob, { o, Z: n.Z, x: o.x * SW, y: o.y * H, s: Math.max(2, o.size * H), at: n.at }, I.objectBox(o, SW, H));
887
1599
  if (ROUND && !o.sky) ob.p = n.at;
1600
+ // its painting as painted is laid over its box where it is now: its glows go with it (a live
1601
+ // one's are where it is repainted)
1602
+ const k = ob.bw / was.bw;
1603
+ if (!ob.live) ob.lights = ob.lights.map(l => ({ ...l, x: ob.bx + (l.x - was.bx) * k, y: ob.by + (l.y - was.by) * k, r: l.r * k, dot: l.dot && l.dot * k }));
1604
+ }
1605
+ return moved;
1606
+ }
1607
+
1608
+ // Where an element's light (l: in its painting's stage px, as lights are kept) is in the world, and
1609
+ // how far it reaches (twice its glow): { at, reach } in eye heights, or null (the sky's)
1610
+ function lightPlace(ob, l) {
1611
+ if (ob.plane) {
1612
+ const P = ob.plane, u = l.x / H / I.EYE, v = l.y / H / I.EYE;
1613
+ return { at: [P.Xc + u * P.cos + v * P.sin, P.Y, P.Zc - v * P.cos + u * P.sin], reach: 2 * l.r / H / I.EYE };
1614
+ }
1615
+ if (ob.ground) {
1616
+ // a row h below the horizon lies 1/h eye heights away, on the ground
1617
+ const below = l.y - YH * H;
1618
+ if (below < 1) return null;
1619
+ const Z = H / below;
1620
+ return { at: [(l.x - SW / 2) / H * Z, 0, Z], reach: 2 * l.r / H * Z };
1621
+ }
1622
+ if (ob.o.sky || !ob.at) return null;
1623
+ const k = ob.at.Zp / H;
1624
+ return { at: [ob.at.X + (l.x - ob.x) * k, ob.at.Y - (l.y - ob.y) * k, ob.at.Z], reach: 2 * l.r * k };
1625
+ }
1626
+ // a light's flicker now, as its glow flickers (drawObjectAt)
1627
+ const flickerOf = (lt, x, y, t) => (lt.fire ? 0.8 + 0.2 * Math.sin(t * 11)
1628
+ : lt.flicker != null ? 1 - lt.flicker * 0.5 * (0.5 + 0.5 * Math.sin(t * 9 + lt.x) * Math.sin(t * 5.3 + lt.y))
1629
+ : 0.9 + 0.1 * Math.sin(t * 7 + x) * Math.sin(t * 3.1 + y));
1630
+ // the light in the air (engine.js: airOf), where it is seen: a source of the spec's halo (`glow`),
1631
+ // or the haze round a point source (the light's hazeGlow)
1632
+ function halo(S, { h, fog }, cam, t) {
1633
+ const p = I.project(cam, ...h.at, W, H, YH);
1634
+ if (!p) return;
1635
+ MD = p[2];
1636
+ const c = h.rgb.map(v => v / 255);
1637
+ if (fog) { const r = h.reach * H / p[2]; sprite(T.glow, ID, p[0], p[1], 2 * r, 2 * r, 0, c, I.airGlow(S.look, h, p[2], t), true); return; }
1638
+ const r = h.halo * H / p[2], a = Math.min(1, h.strength) * I.flickerNow(h, t);
1639
+ sprite(T.glow, ID, p[0], p[1], 2 * r, 2 * r, 0, c, 0.6 * a, true);
1640
+ sprite(T.glow, ID, p[0], p[1], 0.5 * r, 0.5 * r, 0, DOT, 0.8 * a, true);
1641
+ }
1642
+ // the scene's sources now (engine.js: sourcesNow): its own, and its elements' lights as last painted
1643
+ // (sprite: the glow an element's light is drawn with — the light flickers as it does)
1644
+ function sourcesOf(S, spec) {
1645
+ const from = [];
1646
+ for (const ob of S.objects) {
1647
+ if (ob.o.kind === 'bird') continue;
1648
+ for (const l of ob.live?.r ? ob.live.lights : ob.lights) {
1649
+ const q = lightPlace(ob, l);
1650
+ if (q) from.push({ id: l.id ?? String(ob.o.id ?? ob.o.kind), ...q, rgb: l.c.map(v => v * 255), strength: l.a, sprite: { lt: l, x: ob.x, y: ob.y } });
1651
+ }
1652
+ // its parts' (e.part), where each was painted
1653
+ for (const part of ob.parts || []) {
1654
+ const k = part.at.Zp / H;
1655
+ for (const l of part.lights) {
1656
+ const lx = part.bx + l.u * part.bw, ly = part.by + l.v * part.bh;
1657
+ from.push({ id: l.id ?? String(ob.o.id), at: [part.at.X + (lx - part.x) * k, part.at.Y - (ly - part.y) * k, part.at.Z], reach: 2 * l.r * part.bw * k, rgb: l.c.map(v => v * 255), strength: l.a, sprite: { lt: { flicker: l.flicker, x: lx, y: ly }, x: part.x, y: part.y } });
1658
+ }
1659
+ }
888
1660
  }
889
- if (!moved) return;
890
- for (const item of S.order) if (item.ob) item.Z = item.ob.Z;
891
- S.order.sort((a, b) => (b.Z - a.Z) || 0);
1661
+ return I.sourcesNow(spec, S.dz, from);
892
1662
  }
893
1663
 
1664
+ // the scene and the camera this frame (what repaints, painting parts, go by)
1665
+ let SCENE = null, CAM = null;
894
1666
  function frame(S, spec, cam, t, dt, settled, goal) {
895
1667
  frameNo++;
1668
+ SCENE = S; CAM = cam;
896
1669
  BYID = S.byId ??= new Map(S.objects.filter(x => x.o.id != null).map(x => [x.o.id, x]));
897
- placeMoving(S);
1670
+ const moved = placeMoving(S), parted = S.objects.some(ob => ob.parts);
1671
+ if (parted) prepareParts(S, cam, t);
1672
+ if (moved || parted) S.order = orderOf(S);
1673
+ // the land hiding what stands behind its rises: for the camera as it is (kept while it stands)
1674
+ const key = I.land() && `${cam.x}|${cam.y}|${cam.z}|${cam.h}|${cam.yaw}|${W}|${H}`;
1675
+ landCells(S, cam);
1676
+ if (key !== S.landKey) { S.landKey = key; S.land = key ? I.landLine([VX0, VY0, VX1, VY1], S.lands.flatMap(ob => ob.cells)) : null; }
1677
+ LAND = S.land;
1678
+ const look = S.look = I.lookOf(spec);
1679
+ HAZE = look.hazeReach; FOG = look.hazeColor.map(v => v / 255);
1680
+ // lit as much as the light is now (engine.js: lookOf): its programs and its three targets (made
1681
+ // when it is first lit; a browser that can't write depths shows it unlit)
1682
+ S.lit = canLight ? look.lit : 0;
1683
+ P = S.lit ? (LITP ??= programs(true)) : PLAIN;
1684
+ S.scene = S.lit ? (S.litScene ??= litTarget(CW, CH)) : S.plain;
1685
+ S.sources = sourcesOf(S, spec);
1686
+ // (its ambient level: how much a shadow takes away is the source's share of the light)
1687
+ S.ambient = I.ambientOf(look, S.sources).reduce((a, b) => a + b) / 3;
1688
+ const mSky = camMatrix(cam, Infinity);
1689
+ mirrors(S, spec, cam, t, mSky);
898
1690
  gl.bindFramebuffer(gl.FRAMEBUFFER, S.scene.fbo);
899
1691
  gl.viewport(0, 0, CW, CH);
900
- const mSky = camMatrix(cam, Infinity);
901
1692
  gl.clearColor(0, 0, 0, 0); // what nothing paints stays clear: the final pass fills it
902
1693
  gl.clear(gl.COLOR_BUFFER_BIT);
903
1694
  if (S.sky) rect(S.sky, mSky, S.x0, -H, S.span, 3 * H, WHITE, 1, false, [0, -1, 1, 2]);
@@ -907,6 +1698,37 @@ const MooodGL = (() => {
907
1698
  particlesAndPost(S, spec, cam, t, dt, goal);
908
1699
  }
909
1700
 
1701
+ // A frame unseen (engine.js: stage.draw): the scene goes on as if it were drawn — what moves
1702
+ // through the world is put where it is now, what is live is painted (a module's springs and steps
1703
+ // go on, its lights and the point it marks are where they are), particles move, the sharp tiles
1704
+ // for where the camera is are painted ahead — and nothing is drawn: the GPU does nothing. Live
1705
+ // elements are painted coarsely, as what is out of sight is — or, `painted`, each as a drawn frame
1706
+ // paints it (as finely as it is seen: code whose memory depends on how big it is painted goes on
1707
+ // exactly as when drawn).
1708
+ function tick(S, spec, cam, t, dt, goal, painted) {
1709
+ frameNo++;
1710
+ SCENE = S; CAM = cam;
1711
+ BYID = S.byId ??= new Map(S.objects.filter(x => x.o.id != null).map(x => [x.o.id, x]));
1712
+ // as a frame does (frame, above): what moves placed, the parts where they stand (painted with
1713
+ // their element), the order, the light's sources (what reads e.lights is lit as when drawn)
1714
+ const moved = placeMoving(S), parted = S.objects.some(ob => ob.parts);
1715
+ if (parted) {
1716
+ if (painted) prepareParts(S, cam, t);
1717
+ else for (const ob of S.objects) if (ob.parts) { if (ob.live) unseen(ob, t); for (const part of ob.parts) placePart(S, part); }
1718
+ }
1719
+ if (moved || parted) S.order = orderOf(S);
1720
+ S.sources = sourcesOf(S, spec);
1721
+ for (const item of S.order) if (item.ob?.live) { if (painted) drawObjectAt(item.ob, cam, t, false); else unseen(item.ob, t); }
1722
+ const FM = [1, 0, 0, 1, (W - SW) / 2, 0];
1723
+ for (const ob of S.inserts) if (I.opening(ob.o) >= 0.002) { if (painted) textureOf(ob, FM, t); else unseen(ob, t); }
1724
+ for (const { p, list } of S.particles) {
1725
+ const wind = (p.wind ?? 0) * 60 * S.u;
1726
+ for (const q of list) I.moveParticle(p.kind, q, CW, CH, t, dt, S.u, wind);
1727
+ }
1728
+ refine(S, cam, goal || cam);
1729
+ evict(S);
1730
+ }
1731
+
910
1732
  // looking around: the sky, then what stands about, far to near as seen now — planes by the
911
1733
  // farthest reach of them
912
1734
  function drawAround(S, cam, t) {
@@ -923,12 +1745,18 @@ const MooodGL = (() => {
923
1745
  const half = typeof ob.o.facing === 'number' ? ob.bw / 2 / (H / ob.p.Zp) : 0;
924
1746
  items.push({ ob, d: Math.hypot(ob.p.X - cam.x, ob.p.Z - cam.z) + half });
925
1747
  }
1748
+ for (const part of ob.parts || []) items.push({ part, d: Math.hypot(part.p.X - cam.x, part.p.Z - cam.z) });
926
1749
  }
927
- items.sort((a, b) => b.d - a.d);
928
- for (const { ob } of items) {
929
- HZ = ob.plane ? NOHAZE : haze(ob.p.Zp, Math.hypot(ob.p.X - cam.x, ob.p.Z - cam.z));
930
- drawObjectAt(ob, cam, t);
1750
+ for (const a of I.airOf(S.sources, S.look, h => Math.hypot(h.at[0] - cam.x, h.at[2] - cam.z))) items.push({ air: a, d: a.Z });
1751
+ I.arrange(items.sort((a, b) => b.d - a.d), S.rules, idOf);
1752
+ for (const it of items) {
1753
+ if (it.air) { halo(S, it.air, cam, t); continue; }
1754
+ const p = (it.ob || it.part).p;
1755
+ castOn(S, 'ground', it, cam, t);
1756
+ HZ = it.ob?.plane ? NOHAZE : haze(p.Zp, Math.hypot(p.X - cam.x, p.Z - cam.z));
1757
+ drawItem(S, it, cam, t, items);
931
1758
  HZ = NOHAZE;
1759
+ if (it.ob) castOn(S, it.ob, null, cam, t);
932
1760
  }
933
1761
  }
934
1762
 
@@ -939,8 +1767,17 @@ const MooodGL = (() => {
939
1767
  }
940
1768
  for (const c of S.celestials) rect(c.tex, mSky, c.x, c.y, c.size, c.size);
941
1769
 
1770
+ // the light in the air (the halos of the scene's own lights, the haze round its sources), each
1771
+ // at its depth among the rest
1772
+ const halos = I.airOf(S.sources, S.look, h => h.at[2]);
942
1773
  for (const item of S.order) {
943
- if (item.ob) { HZ = isFinite(item.Z) ? haze(item.Z, item.Z - cam.z) : NOHAZE; drawObjectAt(item.ob, cam, t); HZ = NOHAZE; continue; }
1774
+ while (halos.length && halos[0].Z >= item.Z) halo(S, halos.shift(), cam, t);
1775
+ if (!item.l) {
1776
+ castOn(S, 'ground', item, cam, t);
1777
+ HZ = isFinite(item.Z) ? haze(item.Z, item.Z - cam.z) : NOHAZE; drawItem(S, item, cam, t, S.order); HZ = NOHAZE;
1778
+ if (item.ob) castOn(S, item.ob, null, cam, t);
1779
+ continue;
1780
+ }
944
1781
  const l = item.l, L = l.L;
945
1782
  if (l.Z - cam.z < 0.05) continue; // walked past it
946
1783
  const m = camMatrix(cam, l.Z);
@@ -948,6 +1785,7 @@ const MooodGL = (() => {
948
1785
  drawWater(S, l, Math.round(m[5] + m[3] * L.y * H), t);
949
1786
  continue;
950
1787
  }
1788
+ LT = [1, l.Z - cam.z]; MD = l.Z - cam.z;
951
1789
  if (l.tex) {
952
1790
  HZ = haze(l.Z, l.Z - cam.z);
953
1791
  drawLayer(l, m, cam);
@@ -961,12 +1799,15 @@ const MooodGL = (() => {
961
1799
  rect(T.glow, m, cx - b.rx, L.y * H + b.dy - b.ry, 2 * b.rx, 2 * b.ry, l.c, b.a * 2.2);
962
1800
  }
963
1801
  }
1802
+ LT = UNLIT;
964
1803
  }
965
-
1804
+ for (const a of halos) halo(S, a, cam, t);
966
1805
  }
967
1806
 
968
1807
  function particlesAndPost(S, spec, cam, t, dt, goal) {
969
- const u = S.u, PM = [1, 0, 0, 1, VX0, VY0]; // particles live in the canvas's pixels
1808
+ // particles live in the canvas's pixels; in a lit scene a few metres off
1809
+ const u = S.u, PM = [1, 0, 0, 1, VX0, VY0];
1810
+ LT = [1, 3];
970
1811
  for (const { p, c, list } of S.particles) {
971
1812
  const wind = (p.wind ?? 0) * 60 * u;
972
1813
  for (const q of list) {
@@ -987,36 +1828,71 @@ const MooodGL = (() => {
987
1828
  }
988
1829
  }
989
1830
  }
1831
+ LT = UNLIT;
1832
+ // inserts over it all, fixed to the frame
1833
+ const FM = [1, 0, 0, 1, (W - SW) / 2, 0];
1834
+ for (const ob of S.inserts) {
1835
+ if (I.opening(ob.o) < 0.002) continue;
1836
+ const { tx, r } = textureOf(ob, FM, t);
1837
+ rect(tx, FM, r.x, r.y, r.w, r.h);
1838
+ }
990
1839
  flush();
991
1840
 
992
1841
  // final pass to the screen, the whole canvas: the frame at its place in it (S.out)
993
1842
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
994
1843
  gl.viewport(0, 0, CW, CH);
995
- const lt = spec.light || {};
996
- gl.useProgram(pprog);
1844
+ const lt = I.lookOf(spec);
1845
+ gl.useProgram(P.pprog);
997
1846
  gl.activeTexture(gl.TEXTURE0);
998
1847
  gl.bindTexture(gl.TEXTURE_2D, S.scene.tex);
999
- gl.uniform1i(pu.scene, 0);
1000
- gl.uniform2f(pu.res, W, H);
1001
- gl.uniform2f(pu.at, OX, CH - OY - H);
1002
- gl.uniform2f(pu.canvas, CW, CH);
1003
- gl.uniform2f(pu.off, OX, OY);
1004
- gl.uniform3f(pu.tint, ...(lt.tint ? col(lt.tint) : WHITE));
1005
- gl.uniform1f(pu.tintA, lt.tint ? I.clamp(lt.tintStrength ?? 0, 0, 0.5) : 0);
1006
- gl.uniform1f(pu.vig, 0.75 * I.clamp(lt.vignette ?? 0.5, 0, 1));
1007
- gl.uniform1f(pu.grain, 0.1 * I.clamp(lt.grain ?? 0.5, 0, 1));
1008
- gl.uniform1f(pu.seed, Math.floor(Math.random() * 4096));
1848
+ gl.uniform1i(P.pu.scene, 0);
1849
+ gl.uniform2f(P.pu.res, W, H);
1850
+ gl.uniform2f(P.pu.at, OX, CH - OY - H);
1851
+ gl.uniform2f(P.pu.canvas, CW, CH);
1852
+ gl.uniform2f(P.pu.off, OX, OY);
1853
+ gl.uniform3f(P.pu.tint, ...(lt.tint ? lt.tint.map(v => v / 255) : WHITE));
1854
+ gl.uniform1f(P.pu.tintA, lt.tint ? I.clamp(lt.tintStrength, 0, 0.5) : 0);
1855
+ gl.uniform1f(P.pu.vig, 0.75 * I.clamp(lt.vignette, 0, 1));
1856
+ gl.uniform1f(P.pu.grain, 0.1 * I.clamp(lt.grain, 0, 1));
1857
+ gl.uniform1f(P.pu.bright, Math.max(0, lt.brightness));
1858
+ gl.uniform1f(P.pu.sat, Math.max(0, lt.saturation));
1859
+ gl.uniform1f(P.pu.seed, Math.floor(Math.random() * 4096));
1860
+ if (S.lit) lightUniforms(S, lt, cam, t);
1009
1861
  // a film's cut: the frame fades to a colour (state: fade 0…1, fade_color — black by default)
1010
1862
  const sv = I.STATE.values;
1011
- gl.uniform1f(pu.fade, typeof sv.fade === 'number' ? I.clamp(sv.fade, 0, 1) : 0);
1012
- gl.uniform3f(pu.fadeCol, ...(typeof sv.fade_color === 'string' ? col(sv.fade_color) : [0, 0, 0]));
1013
- band(pprog, VY0, VY1);
1863
+ gl.uniform1f(P.pu.fade, typeof sv.fade === 'number' ? I.clamp(sv.fade, 0, 1) : 0);
1864
+ gl.uniform3f(P.pu.fadeCol, ...(typeof sv.fade_color === 'string' ? col(sv.fade_color) : [0, 0, 0]));
1865
+ band(P.pprog, VY0, VY1);
1014
1866
 
1015
1867
  refine(S, cam, goal || cam);
1016
1868
  evict(S);
1017
1869
  }
1018
1870
 
1019
- return { name: 'webgl', fpsIdle: 60, pixelBudget: Infinity, resMax: 2, build, frame };
1871
+ // A lit scene's light for the final pass: the ambient (engine.js: ambientOf), and the LIGHTS point
1872
+ // sources that light most of what is seen from here — the strongest, by how near the camera their
1873
+ // reach comes (one far off lights little of the frame)
1874
+ function lightUniforms(S, lt, cam, t) {
1875
+ const amb = I.ambientOf(lt, S.sources), points = S.sources.filter(s => !s.dir && s.strength > 0.002);
1876
+ const weight = s => s.strength * s.reach / Math.max(s.reach, Math.hypot(s.at[0] - cam.x, s.at[1] - (cam.h ?? 1), s.at[2] - cam.z));
1877
+ points.sort((a, b) => weight(b) - weight(a)).length = Math.min(points.length, LIGHTS);
1878
+ const lp = new Float32Array(4 * LIGHTS), lc = new Float32Array(3 * LIGHTS);
1879
+ points.forEach((s, i) => {
1880
+ const f = s.sprite ? flickerOf(s.sprite.lt, s.sprite.x, s.sprite.y, t) : I.flickerNow(s, t);
1881
+ lp.set([...s.at, s.reach], 4 * i);
1882
+ lc.set(s.rgb.map(v => v / 255 * s.strength * f * lt.lit), 3 * i);
1883
+ });
1884
+ gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, S.scene.lit); gl.uniform1i(P.pu.lit, 1);
1885
+ gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, S.scene.glow); gl.uniform1i(P.pu.glow, 2);
1886
+ gl.activeTexture(gl.TEXTURE0);
1887
+ gl.uniform3f(P.pu.amb, ...amb);
1888
+ gl.uniform1i(P.pu.n, points.length);
1889
+ gl.uniform4fv(P.pu.lp, lp);
1890
+ gl.uniform3fv(P.pu.lc, lc);
1891
+ gl.uniform4f(P.pu.cam, cam.x, cam.z, cam.h ?? 1, cam.yaw || 0);
1892
+ gl.uniform2f(P.pu.view, cam.y, YH);
1893
+ }
1894
+
1895
+ return { name: 'webgl', fpsIdle: 60, pixelBudget: Infinity, resMax: 2, build, frame, tick };
1020
1896
  }
1021
1897
 
1022
1898
  return { create };