@moood/render 0.0.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -29
- package/cli.js +34 -34
- package/engine/actions.js +69 -69
- package/engine/captions.js +20 -20
- package/engine/directions.js +44 -44
- package/engine/engine.js +1456 -1456
- package/engine/engine.json +1 -1
- package/engine/gl.js +1027 -1027
- package/engine/kit.js +142 -142
- package/engine/live.js +23 -23
- package/engine/mic.js +76 -76
- package/engine/music.css +28 -28
- package/engine/music.js +279 -279
- package/engine/nav.css +66 -66
- package/engine/render.html +50 -50
- package/engine/scene.css +124 -124
- package/engine/scenestate.js +220 -220
- package/engine/select.js +301 -301
- package/engine/speech.js +150 -150
- package/engine/volume.js +56 -56
- package/engine/widgets.js +88 -88
- package/index.js +70 -70
- package/package.json +27 -13
- package/scene.js +43 -43
package/engine/gl.js
CHANGED
|
@@ -1,1027 +1,1027 @@
|
|
|
1
|
-
// WebGL2 renderer for the living-scene engine (engine.js). Everything expensive to
|
|
2
|
-
// paint — landscape layers, objects, clouds, celestials — is painted once per scene
|
|
3
|
-
// with engine.js's Canvas 2D code and uploaded as textures. Each frame the GPU places
|
|
4
|
-
// those textures (camera + parallax), draws glows and particles as batched sprites, runs
|
|
5
|
-
// a small shader for water, and one final pass for tint, vignette and film grain.
|
|
6
|
-
// Close up, the edges of the landscape come from sharp tiles painted at the zoom level
|
|
7
|
-
// (like a map), a few per frame, ahead of the camera.
|
|
8
|
-
const MooodGL = (() => {
|
|
9
|
-
const I = Moood.internals;
|
|
10
|
-
const QUADS = 8192, STRIDE = 10; // per vertex: x y u v r g b a, haze k b
|
|
11
|
-
|
|
12
|
-
// Everything is drawn in the frame's pixels (engine.js: the frame is a landscape rectangle inside
|
|
13
|
-
// the canvas, which shows the scene on around it): u_off is where the frame starts in the
|
|
14
|
-
// canvas, u_canvas the canvas's size.
|
|
15
|
-
const VS = `#version 300 es
|
|
16
|
-
in vec2 a_pos; in vec2 a_uv; in vec4 a_col; in vec2 a_haze;
|
|
17
|
-
uniform vec2 u_canvas, u_off;
|
|
18
|
-
out vec2 v_uv; out vec4 v_col; out vec2 v_haze;
|
|
19
|
-
void main() {
|
|
20
|
-
v_uv = a_uv; v_col = a_col; v_haze = a_haze;
|
|
21
|
-
vec2 p = a_pos + u_off;
|
|
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
|
-
}`;
|
|
24
|
-
const FS = `#version 300 es
|
|
25
|
-
precision mediump float;
|
|
26
|
-
in vec2 v_uv; in vec4 v_col; in vec2 v_haze;
|
|
27
|
-
uniform sampler2D u_tex;
|
|
28
|
-
uniform vec3 u_fog;
|
|
29
|
-
out vec4 o;
|
|
30
|
-
// aerial perspective (see haze()): colour·k + fog·b, premultiplied
|
|
31
|
-
void main() {
|
|
32
|
-
o = texture(u_tex, v_uv) * v_col;
|
|
33
|
-
// painted colours don't always hold the haze we assume: never darken more than 40%; an
|
|
34
|
-
// 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);
|
|
36
|
-
}`;
|
|
37
|
-
// full-screen passes: a triangle pair in pixel coordinates
|
|
38
|
-
const QVS = `#version 300 es
|
|
39
|
-
in vec2 a_pos; uniform vec2 u_canvas, u_off;
|
|
40
|
-
void main() { vec2 p = a_pos + u_off; 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); }`;
|
|
41
|
-
// Water: mirror what is drawn above the horizon, ripple it, tint with the water colour.
|
|
42
|
-
const WFS = `#version 300 es
|
|
43
|
-
precision highp float;
|
|
44
|
-
uniform sampler2D u_tex;
|
|
45
|
-
uniform vec2 u_res, u_canvas, u_off; // the frame, the canvas, where the frame starts in it
|
|
46
|
-
uniform float u_wy, u_k, u_t, u_speed, u_u;
|
|
47
|
-
uniform vec3 u_col;
|
|
48
|
-
out vec4 o;
|
|
49
|
-
void main() {
|
|
50
|
-
float px = gl_FragCoord.x - u_off.x, py = u_canvas.y - gl_FragCoord.y - u_off.y;
|
|
51
|
-
float depth = u_res.y - u_wy, d = clamp((py - u_wy) / depth, 0.0, 1.0);
|
|
52
|
-
float dx = (sin(30.0 * log(0.03 + d) - u_t * 1.4 * u_speed) * 0.6 + sin(d * 53.0 + u_t * 0.8) * 0.4) * (1.0 + d * 16.0) * u_u;
|
|
53
|
-
float sy = max(0.0, u_wy - 1.0 - (py - u_wy) * u_k);
|
|
54
|
-
vec3 refl = texture(u_tex, vec2((px + dx + u_off.x) / u_canvas.x, 1.0 - (sy + u_off.y) / u_canvas.y)).rgb;
|
|
55
|
-
vec3 c = mix(refl, u_col, mix(0.35, 0.8, d));
|
|
56
|
-
c += vec3(0.06) * (1.0 - step(u_u, py - u_wy)); // thin bright line on the horizon
|
|
57
|
-
o = vec4(c, 1.0);
|
|
58
|
-
}`;
|
|
59
|
-
// Final pass: soft-light tint, vignette, per-pixel film grain (overlay), fresh every frame.
|
|
60
|
-
const PFS = `#version 300 es
|
|
61
|
-
precision highp float;
|
|
62
|
-
uniform sampler2D u_scene;
|
|
63
|
-
uniform vec2 u_res, u_at, u_canvas; // the frame's size, where it starts in the canvas (window px), the canvas
|
|
64
|
-
uniform vec3 u_tint;
|
|
65
|
-
uniform float u_tintA, u_vig, u_grain, u_seed, u_fade;
|
|
66
|
-
uniform vec3 u_fadeCol;
|
|
67
|
-
out vec4 o;
|
|
68
|
-
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
|
-
vec3 softLight(vec3 b, vec3 s) {
|
|
70
|
-
vec3 d = mix(((16.0 * b - 12.0) * b + 4.0) * b, sqrt(b), step(0.25, b));
|
|
71
|
-
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
|
-
}
|
|
73
|
-
// Around the frame the scene goes on as painted; where nothing is painted (alpha 0), the colour at
|
|
74
|
-
// the frame's nearest edge carries on. Out of the frame it dims gently toward the canvas's edges.
|
|
75
|
-
void main() {
|
|
76
|
-
vec2 q = gl_FragCoord.xy, px = q - u_at;
|
|
77
|
-
vec4 s = texture(u_scene, q / u_canvas);
|
|
78
|
-
vec3 edge = texture(u_scene, clamp(q, u_at + 0.5, u_at + u_res - 0.5) / u_canvas).rgb;
|
|
79
|
-
vec3 c = s.rgb + (1.0 - s.a) * edge;
|
|
80
|
-
c = mix(c, softLight(c, u_tint), u_tintA);
|
|
81
|
-
float r0 = min(u_res.x, u_res.y) * 0.35, r1 = length(u_res) * 0.6;
|
|
82
|
-
c *= 1.0 - u_vig * clamp((length(px - u_res * 0.5) - r0) / (r1 - r0), 0.0, 1.0);
|
|
83
|
-
vec2 out_ = max(max(-px, px - u_res), 0.0);
|
|
84
|
-
c *= 1.0 - 0.45 * smoothstep(0.0, 0.5, length(out_) / u_res.y);
|
|
85
|
-
float n = hash(px + u_seed);
|
|
86
|
-
vec3 ov = mix(2.0 * c * n, 1.0 - 2.0 * (1.0 - c) * (1.0 - n), step(0.5, c));
|
|
87
|
-
o = vec4(mix(mix(c, ov, u_grain), u_fadeCol, u_fade), 1.0);
|
|
88
|
-
}`;
|
|
89
|
-
|
|
90
|
-
function program(gl, vs, fs) {
|
|
91
|
-
const p = gl.createProgram();
|
|
92
|
-
for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
|
|
93
|
-
const s = gl.createShader(type);
|
|
94
|
-
gl.shaderSource(s, src);
|
|
95
|
-
gl.compileShader(s);
|
|
96
|
-
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
|
|
97
|
-
gl.attachShader(p, s);
|
|
98
|
-
}
|
|
99
|
-
gl.bindAttribLocation(p, 0, 'a_pos');
|
|
100
|
-
gl.bindAttribLocation(p, 1, 'a_uv');
|
|
101
|
-
gl.bindAttribLocation(p, 2, 'a_col');
|
|
102
|
-
gl.bindAttribLocation(p, 3, 'a_haze');
|
|
103
|
-
gl.linkProgram(p);
|
|
104
|
-
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
|
|
105
|
-
return p;
|
|
106
|
-
}
|
|
107
|
-
const uniforms = (gl, p, names) => Object.fromEntries(names.map(n => [n, gl.getUniformLocation(p, 'u_' + n)]));
|
|
108
|
-
|
|
109
|
-
// small 2D helpers for sprite textures
|
|
110
|
-
function paint(w, h, fn) { const c = I.layer(w, h); fn(c.getContext('2d'), c.width, c.height); return c; }
|
|
111
|
-
const colours = new Map();
|
|
112
|
-
function col(hexOrRgb) {
|
|
113
|
-
let c = colours.get(hexOrRgb);
|
|
114
|
-
if (!c) {
|
|
115
|
-
const rgb = hexOrRgb.startsWith('#') ? I.rgb(hexOrRgb) : hexOrRgb;
|
|
116
|
-
c = rgb.split(',').map(v => Number(v) / 255);
|
|
117
|
-
colours.set(hexOrRgb, c);
|
|
118
|
-
}
|
|
119
|
-
return c;
|
|
120
|
-
}
|
|
121
|
-
const WHITE = [1, 1, 1];
|
|
122
|
-
|
|
123
|
-
// affine matrices [a b c d e f]: x' = a x + c y + e, y' = b x + d y + f
|
|
124
|
-
const ID = [1, 0, 0, 1, 0, 0];
|
|
125
|
-
const mul = (m, n) => [m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1], m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3], m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5]];
|
|
126
|
-
// rotate by r and scale (sx, sy) around pivot (px, py), then shift by (tx, ty)
|
|
127
|
-
const around = (px, py, r, sx, sy, tx = 0, ty = 0) => {
|
|
128
|
-
const c = Math.cos(r), s = Math.sin(r);
|
|
129
|
-
const a = c * sx, b = s * sx, cc = -s * sy, d = c * sy;
|
|
130
|
-
return [a, b, cc, d, px - a * px - cc * py + tx, py - b * px - d * py + ty];
|
|
131
|
-
};
|
|
132
|
-
const apply = (m, x, y) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
|
|
133
|
-
|
|
134
|
-
function create(canvas) {
|
|
135
|
-
const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false, premultipliedAlpha: true, preserveDrawingBuffer: false });
|
|
136
|
-
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']);
|
|
141
|
-
|
|
142
|
-
const data = new Float32Array(QUADS * 4 * STRIDE);
|
|
143
|
-
const vao = gl.createVertexArray();
|
|
144
|
-
gl.bindVertexArray(vao);
|
|
145
|
-
const vbo = gl.createBuffer();
|
|
146
|
-
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
|
147
|
-
gl.bufferData(gl.ARRAY_BUFFER, data.byteLength, gl.DYNAMIC_DRAW);
|
|
148
|
-
const idx = new Uint16Array(QUADS * 6);
|
|
149
|
-
for (let q = 0; q < QUADS; q++) idx.set([q * 4, q * 4 + 1, q * 4 + 2, q * 4, q * 4 + 2, q * 4 + 3], q * 6);
|
|
150
|
-
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer());
|
|
151
|
-
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idx, gl.STATIC_DRAW);
|
|
152
|
-
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, STRIDE * 4, 0);
|
|
153
|
-
gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 2, gl.FLOAT, false, STRIDE * 4, 8);
|
|
154
|
-
gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 4, gl.FLOAT, false, STRIDE * 4, 16);
|
|
155
|
-
gl.enableVertexAttribArray(3); gl.vertexAttribPointer(3, 2, gl.FLOAT, false, STRIDE * 4, 32);
|
|
156
|
-
const qvao = gl.createVertexArray();
|
|
157
|
-
gl.bindVertexArray(qvao);
|
|
158
|
-
const qvbo = gl.createBuffer();
|
|
159
|
-
gl.bindBuffer(gl.ARRAY_BUFFER, qvbo);
|
|
160
|
-
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 8, 0);
|
|
161
|
-
gl.bindVertexArray(null);
|
|
162
|
-
|
|
163
|
-
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
|
|
164
|
-
gl.enable(gl.BLEND);
|
|
165
|
-
|
|
166
|
-
function texture(source) {
|
|
167
|
-
const t = gl.createTexture();
|
|
168
|
-
gl.bindTexture(gl.TEXTURE_2D, t);
|
|
169
|
-
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
|
170
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
171
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
172
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
173
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
174
|
-
return t;
|
|
175
|
-
}
|
|
176
|
-
// an offscreen render target of the canvas size
|
|
177
|
-
function target(w, h) {
|
|
178
|
-
const tex = gl.createTexture();
|
|
179
|
-
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
180
|
-
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, w, h);
|
|
181
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
182
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
183
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
184
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
185
|
-
const fbo = gl.createFramebuffer();
|
|
186
|
-
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
|
187
|
-
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
|
|
188
|
-
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
189
|
-
return { tex, fbo };
|
|
190
|
-
}
|
|
191
|
-
const dropTarget = r => { gl.deleteTexture(r.tex); gl.deleteFramebuffer(r.fbo); };
|
|
192
|
-
|
|
193
|
-
// textures shared by every scene
|
|
194
|
-
const T = {
|
|
195
|
-
white: texture(paint(2, 2, (g) => { g.fillStyle = '#fff'; g.fillRect(0, 0, 2, 2); })),
|
|
196
|
-
glow: texture(I.glowSprite('255,255,255')),
|
|
197
|
-
dot: texture(paint(32, 32, (g) => { const r = g.createRadialGradient(16, 16, 0, 16, 16, 16); r.addColorStop(0, '#fff'); r.addColorStop(0.55, 'rgba(255,255,255,0.9)'); r.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = r; g.fillRect(0, 0, 32, 32); })),
|
|
198
|
-
ring: texture(paint(32, 32, (g) => { g.strokeStyle = '#fff'; g.lineWidth = 2; g.beginPath(); g.arc(16, 16, 13, 0, 7); g.stroke(); })),
|
|
199
|
-
streak: texture(paint(4, 32, (g) => { const r = g.createLinearGradient(0, 0, 0, 32); r.addColorStop(0, 'rgba(255,255,255,0)'); r.addColorStop(1, '#fff'); g.fillStyle = r; g.fillRect(1, 0, 2, 32); })),
|
|
200
|
-
ellipse: texture(paint(32, 16, (g) => { g.fillStyle = '#fff'; g.beginPath(); g.ellipse(16, 8, 15, 7, 0, 0, 7); g.fill(); })),
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
// ── batching ─────────────────────────────────────────────────────────
|
|
204
|
-
// W × H: the frame; SW: the width of the stage everything is painted on (engine.js);
|
|
205
|
-
// ROUND: the scene is looked around (around, roam) — elements are cards standing in the world
|
|
206
|
-
// OX, OY: where the frame starts in the canvas, CW × CH the canvas (px); what is seen is the
|
|
207
|
-
// canvas — in the frame's pixels from (VX0, VY0) to (VX1, VY1)
|
|
208
|
-
let n = 0, curTex = null, curAdd = false, W = 1, H = 1, SW = 1, ROUND = false;
|
|
209
|
-
let OX = 0, OY = 0, CW = 1, CH = 1, VX0 = 0, VY0 = 0, VX1 = 1, VY1 = 1;
|
|
210
|
-
// Aerial perspective: air between you and a thing tints it toward the horizon's sky
|
|
211
|
-
// colour, h(D) = 1 − e^(−D/HAZE). The scene is painted as seen from the start, so its
|
|
212
|
-
// colours already hold the haze of distance Z; walking to distance D we take that out and
|
|
213
|
-
// 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
|
|
215
|
-
const NOHAZE = [1, 0];
|
|
216
|
-
let HAZE = 220;
|
|
217
|
-
let HZ = NOHAZE, FOG = [0.5, 0.5, 0.5];
|
|
218
|
-
const hz = D => 1 - Math.exp(-Math.max(0, D) / HAZE);
|
|
219
|
-
// painted for distance Z, seen from D away
|
|
220
|
-
const haze = (Z, D) => {
|
|
221
|
-
const h0 = Math.min(0.85, hz(Z)), h1 = hz(D), k = Math.min(3, (1 - h1) / (1 - h0));
|
|
222
|
-
return [k, h1 - h0 * k];
|
|
223
|
-
};
|
|
224
|
-
function flush() {
|
|
225
|
-
if (!n) return;
|
|
226
|
-
gl.useProgram(prog);
|
|
227
|
-
gl.bindVertexArray(vao);
|
|
228
|
-
gl.uniform2f(su.canvas, CW, CH);
|
|
229
|
-
gl.uniform2f(su.off, OX, OY);
|
|
230
|
-
gl.uniform3f(su.fog, ...FOG);
|
|
231
|
-
gl.activeTexture(gl.TEXTURE0);
|
|
232
|
-
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);
|
|
235
|
-
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
|
236
|
-
gl.bufferSubData(gl.ARRAY_BUFFER, 0, data, 0, n * 4 * STRIDE);
|
|
237
|
-
gl.drawElements(gl.TRIANGLES, n * 6, gl.UNSIGNED_SHORT, 0);
|
|
238
|
-
n = 0;
|
|
239
|
-
}
|
|
240
|
-
// one textured quad: the rect (x, y, w, h) transformed by m, uv rect, tint, alpha.
|
|
241
|
-
// Colours are premultiplied; additive quads get alpha 0 so they only add light.
|
|
242
|
-
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;
|
|
245
|
-
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
|
-
const ax = m[0] * w, ay = m[1] * w, bx = m[2] * h, by = m[3] * h;
|
|
248
|
-
const ox = m[0] * x + m[2] * y + m[4], oy = m[1] * x + m[3] * y + m[5];
|
|
249
|
-
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]);
|
|
255
|
-
n++;
|
|
256
|
-
}
|
|
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) {
|
|
259
|
-
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
|
-
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; };
|
|
263
|
-
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
|
-
n++;
|
|
265
|
-
}
|
|
266
|
-
// a textured quad with four free corners, each [x, y, u, v]
|
|
267
|
-
function quadAt(tex, A, B, C_, D) {
|
|
268
|
-
if (tex !== curTex || curAdd || n >= QUADS) { flush(); curTex = tex; curAdd = false; }
|
|
269
|
-
let k = n * 4 * STRIDE;
|
|
270
|
-
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];
|
|
273
|
-
k += STRIDE;
|
|
274
|
-
}
|
|
275
|
-
n++;
|
|
276
|
-
}
|
|
277
|
-
function sprite(tex, m, cx, cy, w, h, rot, c, a, add) {
|
|
278
|
-
rect(tex, rot ? mul(m, around(cx, cy, rot, 1, 1)) : m, cx - w / 2, cy - h / 2, w, h, c, a, add);
|
|
279
|
-
}
|
|
280
|
-
// an opaque band across all that is seen (y0..y1, frame px) drawn with program p (its
|
|
281
|
-
// u_canvas, u_off set)
|
|
282
|
-
function band(p, y0, y1) {
|
|
283
|
-
flush();
|
|
284
|
-
gl.useProgram(p);
|
|
285
|
-
gl.bindVertexArray(qvao);
|
|
286
|
-
gl.bindBuffer(gl.ARRAY_BUFFER, qvbo);
|
|
287
|
-
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([VX0, y0, VX1, y0, VX1, y1, VX0, y0, VX1, y1, VX0, y1]), gl.STREAM_DRAW);
|
|
288
|
-
gl.disable(gl.BLEND);
|
|
289
|
-
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
290
|
-
gl.enable(gl.BLEND);
|
|
291
|
-
curTex = null;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// ── camera ───────────────────────────────────────────────────────────
|
|
295
|
-
let YH = 0.6; // horizon of the current scene, in screen heights
|
|
296
|
-
const camMatrix = (cam, Z) => I.view(cam, Z, W, H, YH);
|
|
297
|
-
// where an element's painting goes from camera c: { m, D } (D: its distance along the gaze),
|
|
298
|
-
// null behind the eye
|
|
299
|
-
const matOf = (ob, c) => (ROUND ? I.billboard(c, ob.p, ob.x, ob.y, W, H, YH) : { m: camMatrix(c, ob.Z), D: ob.Z - c.z });
|
|
300
|
-
// what part of a depth plane is seen, in its own (unscaled) coordinates
|
|
301
|
-
const visible = m => ({ x: (VX0 - m[4]) / m[0], y: (VY0 - m[5]) / m[3], w: (VX1 - VX0) / m[0], h: (VY1 - VY0) / m[3] });
|
|
302
|
-
|
|
303
|
-
// ── landscape layers ─────────────────────────────────────────────────
|
|
304
|
-
// A layer is one colour. Per vertical strip it is empty above `tops`, solid below
|
|
305
|
-
// `solids`: the body is a flat fill, and only the band between (ridges, roofs, trees)
|
|
306
|
-
// needs a texture. Close up, that band is shown from sharp tiles painted at the zoom
|
|
307
|
-
// level (TILE px square, level n = scale LEVEL^n), like a map; a tile that isn't ready
|
|
308
|
-
// yet falls back to a neighbouring level, then to the scene's base texture.
|
|
309
|
-
const TILE = 512, PAD = 1, LEVEL = 1.5, MAX_TILES = 160, TILES_PER_FRAME = 6;
|
|
310
|
-
const levelFor = s => (s <= 1.05 ? 0 : Math.min(7, Math.ceil(Math.log(s) / Math.log(LEVEL) - 1e-6)));
|
|
311
|
-
const scratch = I.layer(TILE + 2 * PAD, TILE + 2 * PAD), pool = [];
|
|
312
|
-
let tileCount = 0, frameNo = 0;
|
|
313
|
-
let BYID = new Map(); // the scene's elements by id (this frame's)
|
|
314
|
-
function tileTexture() {
|
|
315
|
-
if (pool.length) return pool.pop();
|
|
316
|
-
const t = gl.createTexture();
|
|
317
|
-
gl.bindTexture(gl.TEXTURE_2D, t);
|
|
318
|
-
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, TILE + 2 * PAD, TILE + 2 * PAD);
|
|
319
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
320
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
321
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
322
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
323
|
-
return t;
|
|
324
|
-
}
|
|
325
|
-
function freeTile(tile) {
|
|
326
|
-
tileCount--;
|
|
327
|
-
if (pool.length < 16) pool.push(tile.tex); else gl.deleteTexture(tile.tex);
|
|
328
|
-
}
|
|
329
|
-
const TUV = [PAD / (TILE + 2 * PAD), PAD / (TILE + 2 * PAD), 1 - PAD / (TILE + 2 * PAD), 1 - PAD / (TILE + 2 * PAD)];
|
|
330
|
-
const tileSize = n => TILE / Math.pow(LEVEL, n);
|
|
331
|
-
// the band's vertical extent over layer x range [xa, xb]
|
|
332
|
-
function bandOver(l, xa, xb) {
|
|
333
|
-
let top = Infinity, bot = -Infinity;
|
|
334
|
-
for (let i = Math.max(0, Math.floor((xa - l.x0) / l.stripW)); i <= Math.min(l.tops.length - 1, Math.floor((xb - l.x0) / l.stripW)); i++) {
|
|
335
|
-
if (l.solids[i] > l.tops[i]) { top = Math.min(top, l.tops[i]); bot = Math.max(bot, l.solids[i]); }
|
|
336
|
-
}
|
|
337
|
-
return [top, bot];
|
|
338
|
-
}
|
|
339
|
-
// tile cells at level n that cover the band within region v (layer coordinates)
|
|
340
|
-
function cells(l, n, v) {
|
|
341
|
-
const ts = tileSize(n), out = [];
|
|
342
|
-
for (let tx = Math.floor(v.x / ts); tx * ts < v.x + v.w; tx++) {
|
|
343
|
-
const [top, bot] = bandOver(l, tx * ts, (tx + 1) * ts);
|
|
344
|
-
if (!(bot > top)) continue;
|
|
345
|
-
for (let ty = Math.floor(Math.max(v.y, top) / ts); ty * ts < Math.min(v.y + v.h, bot); ty++) out.push({ tx, ty, x: tx * ts, y: ty * ts, s: ts });
|
|
346
|
-
}
|
|
347
|
-
return out;
|
|
348
|
-
}
|
|
349
|
-
function paintTile(l, n, c) {
|
|
350
|
-
const k = Math.pow(LEVEL, n);
|
|
351
|
-
I.staticLayer(l.L, SW, H, l.seed, { x: c.x - PAD / k, y: c.y - PAD / k, w: (TILE + 2 * PAD) / k, h: (TILE + 2 * PAD) / k, k, canvas: scratch });
|
|
352
|
-
const tex = tileTexture();
|
|
353
|
-
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
354
|
-
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, scratch);
|
|
355
|
-
l.tiles.set(`${n}:${c.tx}:${c.ty}`, { tex, used: frameNo, n });
|
|
356
|
-
tileCount++;
|
|
357
|
-
}
|
|
358
|
-
// best available texture for a cell: its own level, else a neighbour level covering it
|
|
359
|
-
function tileFor(l, n, c) {
|
|
360
|
-
for (const m of [n, n - 1, n + 1]) {
|
|
361
|
-
if (m < 1) continue;
|
|
362
|
-
const ts = tileSize(m), tx = Math.floor((c.x + c.s / 2) / ts), ty = Math.floor((c.y + c.s / 2) / ts);
|
|
363
|
-
const t = l.tiles.get(`${m}:${tx}:${ty}`);
|
|
364
|
-
if (t && (m === n || (tx * ts <= c.x + 0.01 && ty * ts <= c.y + 0.01 && (tx + 1) * ts >= c.x + c.s - 0.01 && (ty + 1) * ts >= c.y + c.s - 0.01))) {
|
|
365
|
-
t.used = frameNo;
|
|
366
|
-
const ox = (c.x - tx * ts) / ts, oy = (c.y - ty * ts) / ts, f = c.s / ts, span = TUV[2] - TUV[0];
|
|
367
|
-
return { tex: t.tex, uv: [TUV[0] + ox * span, TUV[1] + oy * span, TUV[0] + (ox + f) * span, TUV[1] + (oy + f) * span] };
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
return null;
|
|
371
|
-
}
|
|
372
|
-
function drawLayer(l, m, cam) {
|
|
373
|
-
const r = l.region, bottom = r.y + r.h, c = col(l.L.color);
|
|
374
|
-
// flat body, strip by strip
|
|
375
|
-
for (let i = 0; i < l.tops.length; i++) {
|
|
376
|
-
const xa = l.x0 + i * l.stripW, xb = xa + l.stripW;
|
|
377
|
-
if (m[0] * xb + m[4] < VX0 || m[0] * xa + m[4] > VX1) continue;
|
|
378
|
-
rect(T.white, m, xa, l.solids[i], l.stripW, bottom + 50 * H - l.solids[i], c);
|
|
379
|
-
}
|
|
380
|
-
const n = levelFor(m[0]);
|
|
381
|
-
if (n === 0) {
|
|
382
|
-
for (let i = 0; i < l.tops.length; i++) {
|
|
383
|
-
const xa = l.x0 + i * l.stripW, xb = xa + l.stripW, ya = l.tops[i], yb = l.solids[i];
|
|
384
|
-
if (yb <= ya || m[0] * xb + m[4] < VX0 || m[0] * xa + m[4] > VX1) continue;
|
|
385
|
-
rect(l.tex, m, xa, ya, xb - xa, yb - ya, WHITE, 1, false, [(xa - r.x) / r.w, (ya - r.y) / r.h, (xb - r.x) / r.w, (yb - r.y) / r.h]);
|
|
386
|
-
}
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
for (const cell of cells(l, n, visible(m))) {
|
|
390
|
-
const t = tileFor(l, n, cell);
|
|
391
|
-
if (t) rect(t.tex, m, cell.x, cell.y, cell.s, cell.s, WHITE, 1, false, t.uv);
|
|
392
|
-
else rect(l.tex, m, cell.x, cell.y, cell.s, cell.s, WHITE, 1, false, [(cell.x - r.x) / r.w, (cell.y - r.y) / r.h, (cell.x + cell.s - r.x) / r.w, (cell.y + cell.s - r.y) / r.h]);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// ── build: paint everything once ─────────────────────────────────────
|
|
397
|
-
// w × h: the frame; out = { x, y, cw, ch }: where it starts in the canvas and the canvas (px)
|
|
398
|
-
function build(spec, w, h, prev, out) {
|
|
399
|
-
if (prev) {
|
|
400
|
-
for (const t of prev.textures) gl.deleteTexture(t);
|
|
401
|
-
dropTarget(prev.scene); dropTarget(prev.copy);
|
|
402
|
-
for (const l of prev.layers) if (l.tiles) for (const t of l.tiles.values()) freeTile(t);
|
|
403
|
-
}
|
|
404
|
-
W = w; H = h; SW = I.STAGE * h; ROUND = I.camSetup(spec).round;
|
|
405
|
-
OX = out.x; OY = out.y; CW = out.cw; CH = out.ch; VX0 = -OX; VY0 = -OY; VX1 = CW - OX; VY1 = CH - OY;
|
|
406
|
-
const textures = new Set(), tex = c => { const t = texture(c); textures.add(t); return t; };
|
|
407
|
-
const u = H / 900, seed = I.hash(JSON.stringify(spec.layers || []) + (spec.title || '')), rnd = I.seeded(seed);
|
|
408
|
-
const x0 = -I.EXT * SW, span = (1 + 2 * I.EXT) * SW, dz = I.depths(spec);
|
|
409
|
-
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;
|
|
412
|
-
|
|
413
|
-
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
|
-
const starCount = Math.round((spec.stars?.density ?? 0) * span * H / 2500 / (u * u));
|
|
415
|
-
const stars = Array.from({ length: starCount }, () => ({ x: x0 + rnd() * span, y: -0.45 * H + Math.pow(rnd(), 1.4) * H * 1.15, r: (0.4 + Math.pow(rnd(), 4) * 1.6) * u, p: rnd() * 7, f: 0.4 + rnd() * 2 }));
|
|
416
|
-
// the sky plane zooms at most ZOOM_MAX^0.32 ≈ 1.4×: paint celestials and birds that much finer
|
|
417
|
-
const SS = 1.5;
|
|
418
|
-
const celestials = (spec.celestials || []).map(c => {
|
|
419
|
-
const r = c.size * H / 2, R = r * 9, size = Math.max(4, Math.ceil(2 * R));
|
|
420
|
-
const canvas = paint(size * SS, size * SS, (g, cw) => {
|
|
421
|
-
g.scale(cw / size, cw / size);
|
|
422
|
-
const cx = size / 2, cy = size / 2, cc = I.rgb(c.color);
|
|
423
|
-
g.globalAlpha = Math.min(1, 0.35 * (c.glow ?? 0.5));
|
|
424
|
-
g.drawImage(I.glowSprite(cc), 0, 0, size, size);
|
|
425
|
-
g.globalAlpha = 1;
|
|
426
|
-
g.fillStyle = c.color; g.beginPath(); g.arc(cx, cy, r, 0, 7); g.fill();
|
|
427
|
-
if (c.kind === 'moon') { g.fillStyle = 'rgba(0,0,0,0.07)'; for (const [dx, dy, rr] of [[-0.3, -0.2, 0.25], [0.28, 0.15, 0.17], [-0.05, 0.4, 0.13]]) { g.beginPath(); g.arc(cx + dx * r, cy + dy * r, rr * r, 0, 7); g.fill(); } }
|
|
428
|
-
if (c.kind === 'planet') { g.strokeStyle = `rgba(${cc},0.6)`; g.lineWidth = r * 0.08; g.beginPath(); g.ellipse(cx, cy, r * 1.8, r * 0.4, -0.3, 0, 7); g.stroke(); }
|
|
429
|
-
});
|
|
430
|
-
return { tex: tex(canvas), x: c.x * SW - size / 2, y: c.y * H - size / 2, size };
|
|
431
|
-
});
|
|
432
|
-
const layers = (spec.layers || []).map((L, i) => {
|
|
433
|
-
const Z = dz.Z[i];
|
|
434
|
-
if (L.kind === 'clouds') {
|
|
435
|
-
const cc = I.rgb(L.color);
|
|
436
|
-
const clouds = Array.from({ length: Math.round((3 + (L.density ?? 0.5) * 9) * (1 + 2 * I.EXT)) }, () => {
|
|
437
|
-
const cw = SW * (0.12 + rnd() * 0.2), ch = cw * 0.35;
|
|
438
|
-
const canvas = paint(cw, ch, (g) => {
|
|
439
|
-
for (let k = 0; k < 7; k++) {
|
|
440
|
-
const gx = cw * (0.2 + rnd() * 0.6), gy = ch * (0.45 + rnd() * 0.25), gr = ch * (0.35 + rnd() * 0.3);
|
|
441
|
-
g.globalAlpha = 0.55; g.drawImage(I.glowSprite(cc), gx - gr, gy - gr, 2 * gr, 2 * gr);
|
|
442
|
-
}
|
|
443
|
-
});
|
|
444
|
-
return { tex: tex(canvas), w: cw, h: ch, x: rnd() * span, y: L.y * H + (rnd() - 0.5) * (L.height ?? 0.1) * H, sp: (4 + rnd() * 8) * u };
|
|
445
|
-
});
|
|
446
|
-
return { L, Z, clouds };
|
|
447
|
-
}
|
|
448
|
-
if (L.kind === 'fog') return { L, Z, c: col(L.color), blobs: Array.from({ length: 10 }, () => ({ x: rnd() * span, dy: (rnd() - 0.5) * (L.height ?? 0.08) * H, rx: SW * (0.2 + rnd() * 0.25), ry: H * (0.03 + rnd() * 0.04), sp: (5 + rnd() * 10) * u, a: 0.06 + rnd() * 0.06 })) };
|
|
449
|
-
if (L.kind === 'water') return { L, Z, c: col(L.color) };
|
|
450
|
-
const lseed = seed + i * 101, st = I.staticLayer(L, SW, H, lseed);
|
|
451
|
-
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
|
-
});
|
|
453
|
-
const objects = (spec.objects || []).map(o0 => {
|
|
454
|
-
if (o0.plane) return planeOf(o0, dz, tex);
|
|
455
|
-
// looking around, the sky is the full circle, 2π stage heights wide (engine.js)
|
|
456
|
-
const o = { ...o0, x: dz.objectX(o0), y: dz.objectY(o0), size: dz.objectSize(o0), _yh: dz.yh, _sw: ROUND && o0.sky ? I.TAU * H : SW }, Z = dz.objectZ(o0);
|
|
457
|
-
const x = o.x * SW, y = o.y * H, s = Math.max(2, o.size * H);
|
|
458
|
-
if (o.kind === 'bird') {
|
|
459
|
-
const canvas = paint(s * 1.2 * SS, s * 0.6 * SS, (g) => {
|
|
460
|
-
g.scale(SS, SS);
|
|
461
|
-
g.strokeStyle = o.color; g.lineCap = 'round'; g.lineWidth = Math.max(1.5, s * 0.08);
|
|
462
|
-
const cx = s * 0.6, cy = s * 0.35;
|
|
463
|
-
g.beginPath(); g.moveTo(cx - s * 0.5, cy - s * 0.1); g.quadraticCurveTo(cx - s * 0.2, cy - s * 0.15, cx, cy);
|
|
464
|
-
g.quadraticCurveTo(cx + s * 0.2, cy - s * 0.15, cx + s * 0.5, cy - s * 0.1); g.stroke();
|
|
465
|
-
});
|
|
466
|
-
return { o, Z, x, y, s, tex: tex(canvas), w: s * 1.2, h: s * 0.6, lights: [] };
|
|
467
|
-
}
|
|
468
|
-
// a custom element whose code reads the time is repainted live every frame (one reading the
|
|
469
|
-
// scene's state, when the state changes)
|
|
470
|
-
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;
|
|
472
|
-
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 ? dz.objectAt(o0) : null,
|
|
476
|
-
live, ground: !!o.ground, from: o0.place ? o0 : null,
|
|
477
|
-
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 })),
|
|
479
|
-
};
|
|
480
|
-
});
|
|
481
|
-
// particles fall over all that is seen: in the canvas's pixels
|
|
482
|
-
const particles = (spec.particles || []).map(p => ({ p, c: col(p.color || '#ffffff'), list: I.makeParticles(p, CW, CH, rnd) }));
|
|
483
|
-
// 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 };
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// Paints an object at t = 0 — the region (bx, by, bw, bh) of it — into a canvas at scale
|
|
489
|
-
// 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 = [];
|
|
492
|
-
const draw = (g) => {
|
|
493
|
-
g.setTransform(k, 0, 0, k, -bx * k, -by * k);
|
|
494
|
-
I.drawObject(g, o, o._sw || SW, H, t, lights);
|
|
495
|
-
for (const l of lights) {
|
|
496
|
-
if (l.rect) { g.fillStyle = `rgba(${l.col},${Math.min(1, l.a * 2.2)})`; g.fillRect(...l.rect); }
|
|
497
|
-
if (l.cone) {
|
|
498
|
-
const gr = g.createLinearGradient(0, l.y, 0, l.cone.y);
|
|
499
|
-
gr.addColorStop(0, `rgba(${l.col},${l.a * 0.35})`); gr.addColorStop(1, `rgba(${l.col},0)`);
|
|
500
|
-
g.fillStyle = gr; g.globalCompositeOperation = 'lighter';
|
|
501
|
-
g.beginPath(); g.moveTo(l.x - l.dot, l.y); g.lineTo(l.x + l.dot, l.y); g.lineTo(l.x + l.cone.w, l.cone.y); g.lineTo(l.x - l.cone.w, l.cone.y); g.fill();
|
|
502
|
-
g.globalCompositeOperation = 'source-over';
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
};
|
|
506
|
-
let canvas = into;
|
|
507
|
-
if (into) {
|
|
508
|
-
const w = Math.max(1, Math.round(bw * k)), h = Math.max(1, Math.round(bh * k));
|
|
509
|
-
if (into.width !== w || into.height !== h) { into.width = w; into.height = h; }
|
|
510
|
-
const g = into.getContext('2d');
|
|
511
|
-
g.setTransform(1, 0, 0, 1, 0, 0);
|
|
512
|
-
g.clearRect(0, 0, w, h);
|
|
513
|
-
draw(g);
|
|
514
|
-
} else canvas = paint(bw * k, bh * k, draw);
|
|
515
|
-
return { canvas, lights };
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// ── sharp close-ups ──────────────────────────────────────────────────
|
|
519
|
-
const MAX_SIDE = 8192;
|
|
520
|
-
const intersect = (a, b) => {
|
|
521
|
-
const x = Math.max(a.x, b.x), y = Math.max(a.y, b.y), w = Math.min(a.x + a.w, b.x + b.w) - x, h = Math.min(a.y + a.h, b.y + b.h) - y;
|
|
522
|
-
return w > 0 && h > 0 ? { x, y, w, h } : null;
|
|
523
|
-
};
|
|
524
|
-
const covers = (r, v) => r.x <= v.x + 0.5 && r.y <= v.y + 0.5 && r.x + r.w >= v.x + v.w - 0.5 && r.y + r.h >= v.y + v.h - 0.5;
|
|
525
|
-
function setDetail(S, owner, det) {
|
|
526
|
-
if (owner.detail) for (const t of owner.detail.frames) { gl.deleteTexture(t); S.textures.delete(t); }
|
|
527
|
-
owner.detail = det;
|
|
528
|
-
if (det) for (const t of det.frames) S.textures.add(t);
|
|
529
|
-
}
|
|
530
|
-
// Each frame, paint a few missing tiles: first for where the camera is heading (`goal`),
|
|
531
|
-
// so the close-up is sharp on arrival, then for where it is now; nearest layer and
|
|
532
|
-
// screen centre first. Objects are small: re-painted whole at the needed scale.
|
|
533
|
-
function refine(S, cam, goal) {
|
|
534
|
-
let budget = TILES_PER_FRAME;
|
|
535
|
-
for (const c of [goal, cam]) {
|
|
536
|
-
for (const l of [...S.layers].reverse()) {
|
|
537
|
-
if (!l.tiles) continue;
|
|
538
|
-
if (l.Z - c.z < 0.05) continue;
|
|
539
|
-
const n = levelFor(camMatrix(c, l.Z)[0]);
|
|
540
|
-
if (n === 0) continue;
|
|
541
|
-
const v = visible(camMatrix(c, l.Z)), cx = v.x + v.w / 2, cy = v.y + v.h / 2;
|
|
542
|
-
const todo = cells(l, n, v).filter(cell => !l.tiles.has(`${n}:${cell.tx}:${cell.ty}`));
|
|
543
|
-
todo.sort((a, b) => Math.hypot(a.x - cx, a.y - cy) - Math.hypot(b.x - cx, b.y - cy));
|
|
544
|
-
for (const cell of todo) {
|
|
545
|
-
if (budget-- <= 0) return;
|
|
546
|
-
paintTile(l, n, cell);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
// objects: re-painted at the needed scale, only the part on screen (a tall tower
|
|
550
|
-
// close up would otherwise be a huge canvas)
|
|
551
|
-
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;
|
|
553
|
-
const at = matOf(ob, c);
|
|
554
|
-
if (!at || at.D < 0.3) continue;
|
|
555
|
-
const k = Math.pow(LEVEL, levelFor(at.m[0]));
|
|
556
|
-
if (k <= 1) { if (ob.detail && c === cam) setDetail(S, ob, null); continue; }
|
|
557
|
-
const v = visible(at.m), box = { x: ob.bx, y: ob.by, w: ob.bw, h: ob.bh };
|
|
558
|
-
const need = intersect(box, v);
|
|
559
|
-
if (!need || (ob.detail && ob.detail.k >= k && covers(ob.detail.region, need))) continue;
|
|
560
|
-
const r = intersect(box, { x: v.x - v.w * 0.15, y: v.y - v.h * 0.15, w: v.w * 1.3, h: v.h * 1.3 });
|
|
561
|
-
// cost in tiles' worth of pixels; one big job may run alone in a frame
|
|
562
|
-
const cost = Math.ceil(r.w * r.h * k * k / (TILE * TILE)) * ob.frames.length;
|
|
563
|
-
if (cost > budget && budget < TILES_PER_FRAME) return;
|
|
564
|
-
budget -= cost;
|
|
565
|
-
const kk = Math.min(k, MAX_SIDE / r.w, MAX_SIDE / r.h);
|
|
566
|
-
const n = ob.frames.length;
|
|
567
|
-
setDetail(S, ob, { frames: ob.frames.map((_, f) => texture(paintObject(ob.o, r.x, r.y, r.w, r.h, kk, f / n * ob.period).canvas)), k, region: r });
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
// keep GPU memory bounded: drop the least recently drawn tiles
|
|
572
|
-
function evict(S) {
|
|
573
|
-
if (tileCount <= MAX_TILES) return;
|
|
574
|
-
const all = [];
|
|
575
|
-
for (const l of S.layers) if (l.tiles) for (const [key, t] of l.tiles) all.push([t.used, l, key, t]);
|
|
576
|
-
all.sort((a, b) => a[0] - b[0]);
|
|
577
|
-
for (const [used, l, key, t] of all.slice(0, tileCount - MAX_TILES)) {
|
|
578
|
-
if (used === frameNo) break;
|
|
579
|
-
l.tiles.delete(key); freeTile(t);
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
function drawWater(S, l, wy, t) {
|
|
584
|
-
flush();
|
|
585
|
-
if (wy >= VY1) return;
|
|
586
|
-
// copy what is drawn so far, then shade the water below the horizon from that copy
|
|
587
|
-
gl.bindFramebuffer(gl.READ_FRAMEBUFFER, S.scene.fbo);
|
|
588
|
-
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, S.copy.fbo);
|
|
589
|
-
gl.blitFramebuffer(0, 0, CW, CH, 0, 0, CW, CH, gl.COLOR_BUFFER_BIT, gl.NEAREST);
|
|
590
|
-
gl.bindFramebuffer(gl.FRAMEBUFFER, S.scene.fbo);
|
|
591
|
-
gl.useProgram(wprog);
|
|
592
|
-
gl.activeTexture(gl.TEXTURE0);
|
|
593
|
-
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);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
const wrapX = (x, S, w) => ((x % (S.span + w)) + S.span + w) % (S.span + w) + S.x0 - w;
|
|
608
|
-
const WINDOW = col('#ffd08a'), DOT = col('255,245,220');
|
|
609
|
-
|
|
610
|
-
// how finely a live element's region r is repainted: the scale it is seen at, up to ~4 Mpx
|
|
611
|
-
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)));
|
|
613
|
-
|
|
614
|
-
// A ground plane (water, fields, a road) is painted as seen from the start. Each row of it
|
|
615
|
-
// lies at a depth — a row h below the horizon is 1/h eye heights away — so as you walk, the
|
|
616
|
-
// rows are laid out again in perspective: drawn as thin strips, each with its own haze.
|
|
617
|
-
function drawGround(ob, cam, t) {
|
|
618
|
-
let tx = ob.tex;
|
|
619
|
-
if (ob.live) {
|
|
620
|
-
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);
|
|
622
|
-
tx = ob.live.tex;
|
|
623
|
-
}
|
|
624
|
-
const yhH = YH * H, top = Math.max(ob.by, yhH + 0.5), bot = ob.by + ob.bh, N = 120, look = -cam.y * H;
|
|
625
|
-
const row = y => {
|
|
626
|
-
const Z0 = H / (y - yhH), D = Z0 - cam.z;
|
|
627
|
-
if (D <= 0.05) return null;
|
|
628
|
-
const s = Z0 / D;
|
|
629
|
-
return { Z0, s, e: (W - s * SW) / 2 - H * cam.x / D, y: yhH + (y - yhH) * s + look + H * ((cam.h ?? 1) - 1) / D };
|
|
630
|
-
};
|
|
631
|
-
let a = row(top);
|
|
632
|
-
for (let i = 1; i <= N && a; i++) {
|
|
633
|
-
const y1 = top + (bot - top) * (i / N) ** 1.6, b = row(y1); // finer near the horizon
|
|
634
|
-
if (!b) break;
|
|
635
|
-
const y0 = top + (bot - top) * ((i - 1) / N) ** 1.6, xl = ob.bx, xr = ob.bx + ob.bw;
|
|
636
|
-
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]);
|
|
639
|
-
}
|
|
640
|
-
if (a.y > VY1) break;
|
|
641
|
-
a = b;
|
|
642
|
-
}
|
|
643
|
-
HZ = NOHAZE;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
// ── horizontal planes ────────────────────────────────────────────────
|
|
647
|
-
// A plane lies flat — a floor, a pier, a table top, a chessboard, a patch of water. The
|
|
648
|
-
// artist paints it from above, in metres, origin at its centre (x to the right, y toward
|
|
649
|
-
// the viewer); it lies `distance` m away (its centre), at `elevation` m, `width` × `depth` m,
|
|
650
|
-
// turned `rotation` degrees about the vertical. It is projected truly for wherever the
|
|
651
|
-
// camera is: drawn as a grid of small cells, each from its own projected corners, with the
|
|
652
|
-
// haze of its own distance. It is drawn at its far edge, before whatever stands on it.
|
|
653
|
-
// World units here are eye heights, as everywhere in the camera: a point (X, Y, Z) is seen
|
|
654
|
-
// at x = W/2 + H·(X − cam.x)/(Z − cam.z), y = yh·H + H·(1 − Y)/(Z − cam.z) − cam.y·H.
|
|
655
|
-
// 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.
|
|
658
|
-
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;
|
|
667
|
-
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);
|
|
670
|
-
}
|
|
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;
|
|
675
|
-
flush(); // quads already batched must be drawn with their own texture first
|
|
676
|
-
gl.bindTexture(gl.TEXTURE_2D, ob.live.tex);
|
|
677
|
-
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, p.canvas);
|
|
678
|
-
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 }));
|
|
682
|
-
}
|
|
683
|
-
const PLANE_TEX = 2048;
|
|
684
|
-
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;
|
|
687
|
-
// 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 };
|
|
689
|
-
const k = PLANE_TEX / (Math.max(w, d) * H), bx = -w / 2 * H, by = -d / 2 * H, bw = w * H, bh = d * H;
|
|
690
|
-
const r0 = paintObject(o, bx, by, bw, bh, k, 0);
|
|
691
|
-
const live = liveOf(o, 0, tex);
|
|
692
|
-
const c = Math.abs(Math.cos(rot)), s = Math.abs(Math.sin(rot));
|
|
693
|
-
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,
|
|
697
|
-
};
|
|
698
|
-
}
|
|
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);
|
|
736
|
-
if (pt.length === 3) quadAt(tex, pt[0], pt[1], pt[2], pt[2]);
|
|
737
|
-
else quadAt(tex, pt[0], pt[1], pt[2], pt[3]);
|
|
738
|
-
if (pt.length === 5) quadAt(tex, pt[0], pt[3], pt[4], pt[4]);
|
|
739
|
-
};
|
|
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;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
function drawPlane(ob, cam, t) {
|
|
745
|
-
let tx = ob.tex;
|
|
746
|
-
if (ob.live) {
|
|
747
|
-
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);
|
|
749
|
-
tx = ob.live.tex;
|
|
750
|
-
}
|
|
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]; });
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
// the texture an element is shown with now: its live painting (repainted when due), else its
|
|
761
|
-
// frame of the loop — the sharp close-up when that covers what is seen of it
|
|
762
|
-
function textureOf(ob, m, t) {
|
|
763
|
-
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
|
-
const seen = ob.live && m ? intersect(whole, visible(m)) : null;
|
|
765
|
-
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;
|
|
770
|
-
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);
|
|
779
|
-
return { tx: ob.live.tex, r: ob.live.r, lights: ob.live.lights };
|
|
780
|
-
}
|
|
781
|
-
const det = ob.detail, onScreen = det && m && intersect(whole, visible(m));
|
|
782
|
-
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 };
|
|
784
|
-
}
|
|
785
|
-
|
|
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;
|
|
795
|
-
const { o, x, y, s } = ob;
|
|
796
|
-
// a platformer's hero: moved and turned from where it is painted
|
|
797
|
-
const m = cam.hero && o.id === cam.hero.id ? mul(at.m, I.heroMatrix(cam.hero, x, s / (o.height || 2))) : at.m;
|
|
798
|
-
if (o.kind === 'bird') {
|
|
799
|
-
// across the widest frame
|
|
800
|
-
const span = I.WIDEST * H + 4 * s, bx = ((x + t * s * 1.5 * (o.flip ? -1 : 1)) % span + span) % span + (SW - I.WIDEST * H) / 2 - 2 * s;
|
|
801
|
-
const by = y + Math.sin(t * 0.7 + x) * s * 0.4;
|
|
802
|
-
// wings flap by squashing the painted bird vertically around its body
|
|
803
|
-
rect(ob.tex, mul(m, [1, 0, 0, 0.3 + 0.7 * Math.sin(t * 8 + x), bx, by]), -ob.w / 2, -ob.h * 0.58, ob.w, ob.h, WHITE, fade);
|
|
804
|
-
return;
|
|
805
|
-
}
|
|
806
|
-
// gentle per-kind motion, applied to the pre-painted object around its base
|
|
807
|
-
let a = ID;
|
|
808
|
-
if (o.kind === 'tree') a = around(x, y, Math.sin(t * 0.8 + x) * 0.015, 1, 1);
|
|
809
|
-
else if (o.kind === 'flower') a = around(x, y, Math.sin(t * 1.4 + x) * 0.06, 1, 1);
|
|
810
|
-
else if (o.kind === 'lantern') a = around(x, y - s * 0.6, Math.sin(t * 1.2 + x) * 0.12, 1, 1);
|
|
811
|
-
else if (o.kind === 'boat') a = around(x, y, 0, 1, 1, 0, Math.sin(t * 1.1 + x) * s * 0.03);
|
|
812
|
-
else if (o.kind === 'figure') a = around(x, y, 0, 1, 1 + Math.sin(t * 1.3) * 0.008);
|
|
813
|
-
else if ((o.kind === 'custom' || !o.kind) && (o.sway || o.bob)) {
|
|
814
|
-
// custom motion hints: sway (degrees, about the base), bob (metres, up and down)
|
|
815
|
-
const ppm = s / (o.height || 2);
|
|
816
|
-
a = around(x, y, Math.sin(t * 0.9 + x) * (o.sway || 0) * Math.PI / 180, 1, 1, 0, Math.sin(t * 1.1 + x) * (o.bob || 0) * ppm);
|
|
817
|
-
}
|
|
818
|
-
// the elements its code reads (`after`: where another figure's hand is now, through a module)
|
|
819
|
-
// are painted first this frame
|
|
820
|
-
for (const id of Array.isArray(o.after) ? o.after : []) {
|
|
821
|
-
const other = BYID.get(id), oat = other?.live && matOf(other, cam);
|
|
822
|
-
if (oat) textureOf(other, oat.m, t);
|
|
823
|
-
}
|
|
824
|
-
const ma = a === ID ? m : mul(m, a), { tx, r, lights } = textureOf(ob, m, t);
|
|
825
|
-
// 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) {
|
|
828
|
-
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);
|
|
830
|
-
}
|
|
831
|
-
rect(tx, ma, r.x, r.y, r.w, r.h, WHITE, fade);
|
|
832
|
-
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
|
-
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);
|
|
838
|
-
if (lt.dot) sprite(T.glow, m, lx, ly, 6 * lt.dot, 6 * lt.dot, 0, DOT, 0.9 * fade, true);
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
// Looking around, an element with `facing` stands turned its own way (a wall, a door, a
|
|
843
|
-
// cupboard): its painting is a vertical card in the world — the painted point (px, py) is
|
|
844
|
-
// (px − x, py − y)/ppm metres from its anchor along the card and down — projected truly,
|
|
845
|
-
// as a grid of small cells like a plane, each with the haze of its own distance.
|
|
846
|
-
function drawCard(ob, cam, t) {
|
|
847
|
-
const p = ob.p, f = ob.o.facing * Math.PI / 180, ppm = H / p.Zp; // painted px per eye height
|
|
848
|
-
const tx_ = -Math.cos(f), tz = Math.sin(f); // along the card, to the right of one facing it
|
|
849
|
-
const { tx, r, lights } = textureOf(ob, null, t);
|
|
850
|
-
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));
|
|
852
|
-
const at = (px, py) => I.project(cam, ...world(px, py), W, H, YH);
|
|
853
|
-
for (const lt of lights) {
|
|
854
|
-
const q = at(lt.x, lt.y);
|
|
855
|
-
if (!q) continue;
|
|
856
|
-
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
|
-
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
|
-
}
|
|
859
|
-
}
|
|
860
|
-
// The sky all around: its painting is the full circle (2π stage heights, straight ahead in
|
|
861
|
-
// the middle); each narrow column of the frame shows the bearing it looks at.
|
|
862
|
-
function drawSkyAround(ob, cam, t) {
|
|
863
|
-
const { tx } = textureOf(ob, null, t), C = 48, top = ob.by - cam.y * H;
|
|
864
|
-
for (let i = 0; i < C; i++) {
|
|
865
|
-
const xa = VX0 + (VX1 - VX0) * i / C, xb = VX0 + (VX1 - VX0) * (i + 1) / C;
|
|
866
|
-
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;
|
|
867
|
-
const base = Math.floor(ua), u0 = ua - base, u1 = ub - base;
|
|
868
|
-
if (u1 <= 1) { rect(tx, ID, xa, top, xb - xa, ob.bh, WHITE, 1, false, [u0, 0, u1, 1]); continue; }
|
|
869
|
-
// the column crosses the seam behind you: both sides of it
|
|
870
|
-
const xm = xa + (xb - xa) * (1 - u0) / (u1 - u0);
|
|
871
|
-
rect(tx, ID, xa, top, xm - xa, ob.bh, WHITE, 1, false, [u0, 0, 1, 1]);
|
|
872
|
-
rect(tx, ID, xm, top, xb - xm, ob.bh, WHITE, 1, false, [0, 0, u1 - 1, 1]);
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
// An element that moves through the world (its `place`: {across, distance, elevation, x} — the
|
|
877
|
-
// names of numbers in the state, metres): put where the state says now — its size, its depth,
|
|
878
|
-
// its haze and what it is drawn in front of or behind follow.
|
|
879
|
-
function placeMoving(S) {
|
|
880
|
-
const v = I.STATE.values;
|
|
881
|
-
let moved = false;
|
|
882
|
-
for (const ob of S.objects) {
|
|
883
|
-
if (!ob.from) continue;
|
|
884
|
-
const o1 = { ...ob.from };
|
|
885
|
-
for (const [f, k] of Object.entries(ob.from.place)) if (typeof k === 'string' && typeof v[k] === 'number') o1[f] = v[k];
|
|
886
|
-
const key = `${o1.x}|${o1.across}|${o1.distance}|${o1.elevation}`;
|
|
887
|
-
if (key === ob.placed) continue;
|
|
888
|
-
ob.placed = key; moved = true;
|
|
889
|
-
const o = { ...ob.o, x: S.dz.objectX(o1), y: S.dz.objectY(o1), size: S.dz.objectSize(o1) };
|
|
890
|
-
Object.assign(ob, { o, Z: S.dz.objectZ(o1), x: o.x * SW, y: o.y * H, s: Math.max(2, o.size * H) }, I.objectBox(o, SW, H));
|
|
891
|
-
if (ROUND && !o.sky) ob.p = S.dz.objectAt(o1);
|
|
892
|
-
}
|
|
893
|
-
if (!moved) return;
|
|
894
|
-
for (const item of S.order) if (item.ob) item.Z = item.ob.Z;
|
|
895
|
-
S.order.sort((a, b) => (b.Z - a.Z) || 0);
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
function frame(S, spec, cam, t, dt, settled, goal) {
|
|
899
|
-
frameNo++;
|
|
900
|
-
BYID = S.byId ??= new Map(S.objects.filter(x => x.o.id != null).map(x => [x.o.id, x]));
|
|
901
|
-
placeMoving(S);
|
|
902
|
-
gl.bindFramebuffer(gl.FRAMEBUFFER, S.scene.fbo);
|
|
903
|
-
gl.viewport(0, 0, CW, CH);
|
|
904
|
-
const mSky = camMatrix(cam, Infinity);
|
|
905
|
-
gl.clearColor(0, 0, 0, 0); // what nothing paints stays clear: the final pass fills it
|
|
906
|
-
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
907
|
-
if (S.sky) rect(S.sky, mSky, S.x0, -H, S.span, 3 * H, WHITE, 1, false, [0, -1, 1, 2]);
|
|
908
|
-
|
|
909
|
-
if (ROUND) drawAround(S, cam, t);
|
|
910
|
-
else drawAhead(S, spec, cam, t, mSky);
|
|
911
|
-
particlesAndPost(S, spec, cam, t, dt, goal);
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
// looking around: the sky, then what stands about, far to near as seen now — planes by the
|
|
915
|
-
// farthest reach of them
|
|
916
|
-
function drawAround(S, cam, t) {
|
|
917
|
-
for (const ob of S.objects) if (ob.o.sky) drawObjectAt(ob, cam, t);
|
|
918
|
-
const items = [];
|
|
919
|
-
for (const ob of S.objects) {
|
|
920
|
-
if (ob.o.sky || ob.ground) continue;
|
|
921
|
-
if (ob.plane) {
|
|
922
|
-
const P = ob.plane;
|
|
923
|
-
items.push({ ob, d: Math.hypot(P.Xc - cam.x, P.Zc - cam.z) + Math.max(P.w, P.d) / 2 });
|
|
924
|
-
} else {
|
|
925
|
-
// a card turned its own way (a wall) by its farthest reach too: what stands in front of
|
|
926
|
-
// it is drawn after it
|
|
927
|
-
const half = typeof ob.o.facing === 'number' ? ob.bw / 2 / (H / ob.p.Zp) : 0;
|
|
928
|
-
items.push({ ob, d: Math.hypot(ob.p.X - cam.x, ob.p.Z - cam.z) + half });
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
items.sort((a, b) => b.d - a.d);
|
|
932
|
-
for (const { ob } of items) {
|
|
933
|
-
HZ = ob.plane ? NOHAZE : haze(ob.p.Zp, Math.hypot(ob.p.X - cam.x, ob.p.Z - cam.z));
|
|
934
|
-
drawObjectAt(ob, cam, t);
|
|
935
|
-
HZ = NOHAZE;
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
function drawAhead(S, spec, cam, t, mSky) {
|
|
940
|
-
if (S.stars.length) {
|
|
941
|
-
const sc = col(spec.stars?.color || '#ffffff');
|
|
942
|
-
for (const s of S.stars) sprite(T.dot, mSky, s.x, s.y, 2.6 * s.r, 2.6 * s.r, 0, sc, 0.25 + 0.75 * Math.pow(0.5 + 0.5 * Math.sin(t * s.f + s.p), 2), false);
|
|
943
|
-
}
|
|
944
|
-
for (const c of S.celestials) rect(c.tex, mSky, c.x, c.y, c.size, c.size);
|
|
945
|
-
|
|
946
|
-
for (const item of S.order) {
|
|
947
|
-
if (item.ob) { HZ = isFinite(item.Z) ? haze(item.Z, item.Z - cam.z) : NOHAZE; drawObjectAt(item.ob, cam, t); HZ = NOHAZE; continue; }
|
|
948
|
-
const l = item.l, L = l.L;
|
|
949
|
-
if (l.Z - cam.z < 0.05) continue; // walked past it
|
|
950
|
-
const m = camMatrix(cam, l.Z);
|
|
951
|
-
if (L.kind === 'water') {
|
|
952
|
-
drawWater(S, l, Math.round(m[5] + m[3] * L.y * H), t);
|
|
953
|
-
continue;
|
|
954
|
-
}
|
|
955
|
-
if (l.tex) {
|
|
956
|
-
HZ = haze(l.Z, l.Z - cam.z);
|
|
957
|
-
drawLayer(l, m, cam);
|
|
958
|
-
for (const w of l.windows) if (Math.sin(t * 0.07 + w.p) > -0.85) rect(T.white, m, w.x, w.y, w.w, w.h, WINDOW, 0.55 + 0.25 * Math.sin(w.p));
|
|
959
|
-
HZ = NOHAZE;
|
|
960
|
-
} else if (L.kind === 'clouds') {
|
|
961
|
-
for (const c of l.clouds) rect(c.tex, m, wrapX(c.x + t * c.sp * (L.drift ?? 0.3) * 3, S, c.w), c.y - c.h / 2, c.w, c.h);
|
|
962
|
-
} else if (L.kind === 'fog') {
|
|
963
|
-
for (const b of l.blobs) {
|
|
964
|
-
const cx = wrapX(b.x + t * b.sp * (L.drift ?? 0.3) * 2, S, 2 * b.rx) + b.rx;
|
|
965
|
-
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);
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
function particlesAndPost(S, spec, cam, t, dt, goal) {
|
|
973
|
-
const u = S.u, PM = [1, 0, 0, 1, VX0, VY0]; // particles live in the canvas's pixels
|
|
974
|
-
for (const { p, c, list } of S.particles) {
|
|
975
|
-
const wind = (p.wind ?? 0) * 60 * u;
|
|
976
|
-
for (const q of list) {
|
|
977
|
-
I.moveParticle(p.kind, q, CW, CH, t, dt, u, wind);
|
|
978
|
-
const r = (1 + q.z * 3) * u;
|
|
979
|
-
switch (p.kind) {
|
|
980
|
-
case 'rain': { const len = (10 + q.z * 16) * u; rect(T.streak, PM, q.x - Math.max(1, u), q.y, 2 * Math.max(1, u), len, c, 0.28); break; }
|
|
981
|
-
case 'fireflies': sprite(T.glow, PM, q.x, q.y, 36 * u, 36 * u, 0, c, 0.6 * (0.25 + 0.75 * Math.pow(0.5 + 0.5 * Math.sin(t * 1.6 + q.ph * 3), 3)), true); break;
|
|
982
|
-
case 'embers': sprite(T.glow, PM, q.x, q.y, 16 * u, 16 * u, 0, c, 0.6 * (0.5 + 0.5 * Math.sin(t * 5 + q.ph)), true); break;
|
|
983
|
-
case 'petals': case 'leaves': {
|
|
984
|
-
const k = p.kind === 'leaves' ? 2.4 : 1.6;
|
|
985
|
-
rect(T.ellipse, mul(PM, around(q.x, q.y, t * (0.5 + q.z) + q.ph, 1, Math.abs(Math.sin(t * 2 + q.ph)) * 0.8 + 0.2)), q.x - r * k, q.y - r, 2 * r * k, 2 * r, c, 0.85);
|
|
986
|
-
break;
|
|
987
|
-
}
|
|
988
|
-
case 'bubbles': sprite(T.ring, PM, q.x, q.y, 3.4 * r, 3.4 * r, 0, c, 0.5, false); break;
|
|
989
|
-
case 'dust': sprite(T.dot, PM, q.x, q.y, r * 1.2, r * 1.2, 0, c, 0.35, false); break;
|
|
990
|
-
default: sprite(T.dot, PM, q.x, q.y, r * 2.4, r * 2.4, 0, c, 0.3 + q.z * 0.55, false);
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
flush();
|
|
995
|
-
|
|
996
|
-
// final pass to the screen, the whole canvas: the frame at its place in it (S.out)
|
|
997
|
-
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
998
|
-
gl.viewport(0, 0, CW, CH);
|
|
999
|
-
const lt = spec.light || {};
|
|
1000
|
-
gl.useProgram(pprog);
|
|
1001
|
-
gl.activeTexture(gl.TEXTURE0);
|
|
1002
|
-
gl.bindTexture(gl.TEXTURE_2D, S.scene.tex);
|
|
1003
|
-
gl.uniform1i(pu.scene, 0);
|
|
1004
|
-
gl.uniform2f(pu.res, W, H);
|
|
1005
|
-
gl.uniform2f(pu.at, OX, CH - OY - H);
|
|
1006
|
-
gl.uniform2f(pu.canvas, CW, CH);
|
|
1007
|
-
gl.uniform2f(pu.off, OX, OY);
|
|
1008
|
-
gl.uniform3f(pu.tint, ...(lt.tint ? col(lt.tint) : WHITE));
|
|
1009
|
-
gl.uniform1f(pu.tintA, lt.tint ? I.clamp(lt.tintStrength ?? 0, 0, 0.5) : 0);
|
|
1010
|
-
gl.uniform1f(pu.vig, 0.75 * I.clamp(lt.vignette ?? 0.5, 0, 1));
|
|
1011
|
-
gl.uniform1f(pu.grain, 0.1 * I.clamp(lt.grain ?? 0.5, 0, 1));
|
|
1012
|
-
gl.uniform1f(pu.seed, Math.floor(Math.random() * 4096));
|
|
1013
|
-
// a film's cut: the frame fades to a colour (state: fade 0…1, fade_color — black by default)
|
|
1014
|
-
const sv = I.STATE.values;
|
|
1015
|
-
gl.uniform1f(pu.fade, typeof sv.fade === 'number' ? I.clamp(sv.fade, 0, 1) : 0);
|
|
1016
|
-
gl.uniform3f(pu.fadeCol, ...(typeof sv.fade_color === 'string' ? col(sv.fade_color) : [0, 0, 0]));
|
|
1017
|
-
band(pprog, VY0, VY1);
|
|
1018
|
-
|
|
1019
|
-
refine(S, cam, goal || cam);
|
|
1020
|
-
evict(S);
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
return { name: 'webgl', fpsIdle: 60, pixelBudget: Infinity, resMax: 2, build, frame };
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
return { create };
|
|
1027
|
-
})();
|
|
1
|
+
// WebGL2 renderer for the living-scene engine (engine.js). Everything expensive to
|
|
2
|
+
// paint — landscape layers, objects, clouds, celestials — is painted once per scene
|
|
3
|
+
// with engine.js's Canvas 2D code and uploaded as textures. Each frame the GPU places
|
|
4
|
+
// those textures (camera + parallax), draws glows and particles as batched sprites, runs
|
|
5
|
+
// a small shader for water, and one final pass for tint, vignette and film grain.
|
|
6
|
+
// Close up, the edges of the landscape come from sharp tiles painted at the zoom level
|
|
7
|
+
// (like a map), a few per frame, ahead of the camera.
|
|
8
|
+
const MooodGL = (() => {
|
|
9
|
+
const I = Moood.internals;
|
|
10
|
+
const QUADS = 8192, STRIDE = 10; // per vertex: x y u v r g b a, haze k b
|
|
11
|
+
|
|
12
|
+
// Everything is drawn in the frame's pixels (engine.js: the frame is a landscape rectangle inside
|
|
13
|
+
// the canvas, which shows the scene on around it): u_off is where the frame starts in the
|
|
14
|
+
// canvas, u_canvas the canvas's size.
|
|
15
|
+
const VS = `#version 300 es
|
|
16
|
+
in vec2 a_pos; in vec2 a_uv; in vec4 a_col; in vec2 a_haze;
|
|
17
|
+
uniform vec2 u_canvas, u_off;
|
|
18
|
+
out vec2 v_uv; out vec4 v_col; out vec2 v_haze;
|
|
19
|
+
void main() {
|
|
20
|
+
v_uv = a_uv; v_col = a_col; v_haze = a_haze;
|
|
21
|
+
vec2 p = a_pos + u_off;
|
|
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
|
+
}`;
|
|
24
|
+
const FS = `#version 300 es
|
|
25
|
+
precision mediump float;
|
|
26
|
+
in vec2 v_uv; in vec4 v_col; in vec2 v_haze;
|
|
27
|
+
uniform sampler2D u_tex;
|
|
28
|
+
uniform vec3 u_fog;
|
|
29
|
+
out vec4 o;
|
|
30
|
+
// aerial perspective (see haze()): colour·k + fog·b, premultiplied
|
|
31
|
+
void main() {
|
|
32
|
+
o = texture(u_tex, v_uv) * v_col;
|
|
33
|
+
// painted colours don't always hold the haze we assume: never darken more than 40%; an
|
|
34
|
+
// 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);
|
|
36
|
+
}`;
|
|
37
|
+
// full-screen passes: a triangle pair in pixel coordinates
|
|
38
|
+
const QVS = `#version 300 es
|
|
39
|
+
in vec2 a_pos; uniform vec2 u_canvas, u_off;
|
|
40
|
+
void main() { vec2 p = a_pos + u_off; 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); }`;
|
|
41
|
+
// Water: mirror what is drawn above the horizon, ripple it, tint with the water colour.
|
|
42
|
+
const WFS = `#version 300 es
|
|
43
|
+
precision highp float;
|
|
44
|
+
uniform sampler2D u_tex;
|
|
45
|
+
uniform vec2 u_res, u_canvas, u_off; // the frame, the canvas, where the frame starts in it
|
|
46
|
+
uniform float u_wy, u_k, u_t, u_speed, u_u;
|
|
47
|
+
uniform vec3 u_col;
|
|
48
|
+
out vec4 o;
|
|
49
|
+
void main() {
|
|
50
|
+
float px = gl_FragCoord.x - u_off.x, py = u_canvas.y - gl_FragCoord.y - u_off.y;
|
|
51
|
+
float depth = u_res.y - u_wy, d = clamp((py - u_wy) / depth, 0.0, 1.0);
|
|
52
|
+
float dx = (sin(30.0 * log(0.03 + d) - u_t * 1.4 * u_speed) * 0.6 + sin(d * 53.0 + u_t * 0.8) * 0.4) * (1.0 + d * 16.0) * u_u;
|
|
53
|
+
float sy = max(0.0, u_wy - 1.0 - (py - u_wy) * u_k);
|
|
54
|
+
vec3 refl = texture(u_tex, vec2((px + dx + u_off.x) / u_canvas.x, 1.0 - (sy + u_off.y) / u_canvas.y)).rgb;
|
|
55
|
+
vec3 c = mix(refl, u_col, mix(0.35, 0.8, d));
|
|
56
|
+
c += vec3(0.06) * (1.0 - step(u_u, py - u_wy)); // thin bright line on the horizon
|
|
57
|
+
o = vec4(c, 1.0);
|
|
58
|
+
}`;
|
|
59
|
+
// Final pass: soft-light tint, vignette, per-pixel film grain (overlay), fresh every frame.
|
|
60
|
+
const PFS = `#version 300 es
|
|
61
|
+
precision highp float;
|
|
62
|
+
uniform sampler2D u_scene;
|
|
63
|
+
uniform vec2 u_res, u_at, u_canvas; // the frame's size, where it starts in the canvas (window px), the canvas
|
|
64
|
+
uniform vec3 u_tint;
|
|
65
|
+
uniform float u_tintA, u_vig, u_grain, u_seed, u_fade;
|
|
66
|
+
uniform vec3 u_fadeCol;
|
|
67
|
+
out vec4 o;
|
|
68
|
+
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
|
+
vec3 softLight(vec3 b, vec3 s) {
|
|
70
|
+
vec3 d = mix(((16.0 * b - 12.0) * b + 4.0) * b, sqrt(b), step(0.25, b));
|
|
71
|
+
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
|
+
}
|
|
73
|
+
// Around the frame the scene goes on as painted; where nothing is painted (alpha 0), the colour at
|
|
74
|
+
// the frame's nearest edge carries on. Out of the frame it dims gently toward the canvas's edges.
|
|
75
|
+
void main() {
|
|
76
|
+
vec2 q = gl_FragCoord.xy, px = q - u_at;
|
|
77
|
+
vec4 s = texture(u_scene, q / u_canvas);
|
|
78
|
+
vec3 edge = texture(u_scene, clamp(q, u_at + 0.5, u_at + u_res - 0.5) / u_canvas).rgb;
|
|
79
|
+
vec3 c = s.rgb + (1.0 - s.a) * edge;
|
|
80
|
+
c = mix(c, softLight(c, u_tint), u_tintA);
|
|
81
|
+
float r0 = min(u_res.x, u_res.y) * 0.35, r1 = length(u_res) * 0.6;
|
|
82
|
+
c *= 1.0 - u_vig * clamp((length(px - u_res * 0.5) - r0) / (r1 - r0), 0.0, 1.0);
|
|
83
|
+
vec2 out_ = max(max(-px, px - u_res), 0.0);
|
|
84
|
+
c *= 1.0 - 0.45 * smoothstep(0.0, 0.5, length(out_) / u_res.y);
|
|
85
|
+
float n = hash(px + u_seed);
|
|
86
|
+
vec3 ov = mix(2.0 * c * n, 1.0 - 2.0 * (1.0 - c) * (1.0 - n), step(0.5, c));
|
|
87
|
+
o = vec4(mix(mix(c, ov, u_grain), u_fadeCol, u_fade), 1.0);
|
|
88
|
+
}`;
|
|
89
|
+
|
|
90
|
+
function program(gl, vs, fs) {
|
|
91
|
+
const p = gl.createProgram();
|
|
92
|
+
for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
|
|
93
|
+
const s = gl.createShader(type);
|
|
94
|
+
gl.shaderSource(s, src);
|
|
95
|
+
gl.compileShader(s);
|
|
96
|
+
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
|
|
97
|
+
gl.attachShader(p, s);
|
|
98
|
+
}
|
|
99
|
+
gl.bindAttribLocation(p, 0, 'a_pos');
|
|
100
|
+
gl.bindAttribLocation(p, 1, 'a_uv');
|
|
101
|
+
gl.bindAttribLocation(p, 2, 'a_col');
|
|
102
|
+
gl.bindAttribLocation(p, 3, 'a_haze');
|
|
103
|
+
gl.linkProgram(p);
|
|
104
|
+
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
|
|
105
|
+
return p;
|
|
106
|
+
}
|
|
107
|
+
const uniforms = (gl, p, names) => Object.fromEntries(names.map(n => [n, gl.getUniformLocation(p, 'u_' + n)]));
|
|
108
|
+
|
|
109
|
+
// small 2D helpers for sprite textures
|
|
110
|
+
function paint(w, h, fn) { const c = I.layer(w, h); fn(c.getContext('2d'), c.width, c.height); return c; }
|
|
111
|
+
const colours = new Map();
|
|
112
|
+
function col(hexOrRgb) {
|
|
113
|
+
let c = colours.get(hexOrRgb);
|
|
114
|
+
if (!c) {
|
|
115
|
+
const rgb = hexOrRgb.startsWith('#') ? I.rgb(hexOrRgb) : hexOrRgb;
|
|
116
|
+
c = rgb.split(',').map(v => Number(v) / 255);
|
|
117
|
+
colours.set(hexOrRgb, c);
|
|
118
|
+
}
|
|
119
|
+
return c;
|
|
120
|
+
}
|
|
121
|
+
const WHITE = [1, 1, 1];
|
|
122
|
+
|
|
123
|
+
// affine matrices [a b c d e f]: x' = a x + c y + e, y' = b x + d y + f
|
|
124
|
+
const ID = [1, 0, 0, 1, 0, 0];
|
|
125
|
+
const mul = (m, n) => [m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1], m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3], m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5]];
|
|
126
|
+
// rotate by r and scale (sx, sy) around pivot (px, py), then shift by (tx, ty)
|
|
127
|
+
const around = (px, py, r, sx, sy, tx = 0, ty = 0) => {
|
|
128
|
+
const c = Math.cos(r), s = Math.sin(r);
|
|
129
|
+
const a = c * sx, b = s * sx, cc = -s * sy, d = c * sy;
|
|
130
|
+
return [a, b, cc, d, px - a * px - cc * py + tx, py - b * px - d * py + ty];
|
|
131
|
+
};
|
|
132
|
+
const apply = (m, x, y) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
|
|
133
|
+
|
|
134
|
+
function create(canvas) {
|
|
135
|
+
const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false, premultipliedAlpha: true, preserveDrawingBuffer: false });
|
|
136
|
+
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']);
|
|
141
|
+
|
|
142
|
+
const data = new Float32Array(QUADS * 4 * STRIDE);
|
|
143
|
+
const vao = gl.createVertexArray();
|
|
144
|
+
gl.bindVertexArray(vao);
|
|
145
|
+
const vbo = gl.createBuffer();
|
|
146
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
|
147
|
+
gl.bufferData(gl.ARRAY_BUFFER, data.byteLength, gl.DYNAMIC_DRAW);
|
|
148
|
+
const idx = new Uint16Array(QUADS * 6);
|
|
149
|
+
for (let q = 0; q < QUADS; q++) idx.set([q * 4, q * 4 + 1, q * 4 + 2, q * 4, q * 4 + 2, q * 4 + 3], q * 6);
|
|
150
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer());
|
|
151
|
+
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idx, gl.STATIC_DRAW);
|
|
152
|
+
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, STRIDE * 4, 0);
|
|
153
|
+
gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 2, gl.FLOAT, false, STRIDE * 4, 8);
|
|
154
|
+
gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 4, gl.FLOAT, false, STRIDE * 4, 16);
|
|
155
|
+
gl.enableVertexAttribArray(3); gl.vertexAttribPointer(3, 2, gl.FLOAT, false, STRIDE * 4, 32);
|
|
156
|
+
const qvao = gl.createVertexArray();
|
|
157
|
+
gl.bindVertexArray(qvao);
|
|
158
|
+
const qvbo = gl.createBuffer();
|
|
159
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, qvbo);
|
|
160
|
+
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 8, 0);
|
|
161
|
+
gl.bindVertexArray(null);
|
|
162
|
+
|
|
163
|
+
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
|
|
164
|
+
gl.enable(gl.BLEND);
|
|
165
|
+
|
|
166
|
+
function texture(source) {
|
|
167
|
+
const t = gl.createTexture();
|
|
168
|
+
gl.bindTexture(gl.TEXTURE_2D, t);
|
|
169
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
|
170
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
171
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
172
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
173
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
174
|
+
return t;
|
|
175
|
+
}
|
|
176
|
+
// an offscreen render target of the canvas size
|
|
177
|
+
function target(w, h) {
|
|
178
|
+
const tex = gl.createTexture();
|
|
179
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
180
|
+
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, w, h);
|
|
181
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
182
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
183
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
184
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
185
|
+
const fbo = gl.createFramebuffer();
|
|
186
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
|
187
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
|
|
188
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
189
|
+
return { tex, fbo };
|
|
190
|
+
}
|
|
191
|
+
const dropTarget = r => { gl.deleteTexture(r.tex); gl.deleteFramebuffer(r.fbo); };
|
|
192
|
+
|
|
193
|
+
// textures shared by every scene
|
|
194
|
+
const T = {
|
|
195
|
+
white: texture(paint(2, 2, (g) => { g.fillStyle = '#fff'; g.fillRect(0, 0, 2, 2); })),
|
|
196
|
+
glow: texture(I.glowSprite('255,255,255')),
|
|
197
|
+
dot: texture(paint(32, 32, (g) => { const r = g.createRadialGradient(16, 16, 0, 16, 16, 16); r.addColorStop(0, '#fff'); r.addColorStop(0.55, 'rgba(255,255,255,0.9)'); r.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = r; g.fillRect(0, 0, 32, 32); })),
|
|
198
|
+
ring: texture(paint(32, 32, (g) => { g.strokeStyle = '#fff'; g.lineWidth = 2; g.beginPath(); g.arc(16, 16, 13, 0, 7); g.stroke(); })),
|
|
199
|
+
streak: texture(paint(4, 32, (g) => { const r = g.createLinearGradient(0, 0, 0, 32); r.addColorStop(0, 'rgba(255,255,255,0)'); r.addColorStop(1, '#fff'); g.fillStyle = r; g.fillRect(1, 0, 2, 32); })),
|
|
200
|
+
ellipse: texture(paint(32, 16, (g) => { g.fillStyle = '#fff'; g.beginPath(); g.ellipse(16, 8, 15, 7, 0, 0, 7); g.fill(); })),
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// ── batching ─────────────────────────────────────────────────────────
|
|
204
|
+
// W × H: the frame; SW: the width of the stage everything is painted on (engine.js);
|
|
205
|
+
// ROUND: the scene is looked around (around, roam) — elements are cards standing in the world
|
|
206
|
+
// OX, OY: where the frame starts in the canvas, CW × CH the canvas (px); what is seen is the
|
|
207
|
+
// canvas — in the frame's pixels from (VX0, VY0) to (VX1, VY1)
|
|
208
|
+
let n = 0, curTex = null, curAdd = false, W = 1, H = 1, SW = 1, ROUND = false;
|
|
209
|
+
let OX = 0, OY = 0, CW = 1, CH = 1, VX0 = 0, VY0 = 0, VX1 = 1, VY1 = 1;
|
|
210
|
+
// Aerial perspective: air between you and a thing tints it toward the horizon's sky
|
|
211
|
+
// colour, h(D) = 1 − e^(−D/HAZE). The scene is painted as seen from the start, so its
|
|
212
|
+
// colours already hold the haze of distance Z; walking to distance D we take that out and
|
|
213
|
+
// 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
|
|
215
|
+
const NOHAZE = [1, 0];
|
|
216
|
+
let HAZE = 220;
|
|
217
|
+
let HZ = NOHAZE, FOG = [0.5, 0.5, 0.5];
|
|
218
|
+
const hz = D => 1 - Math.exp(-Math.max(0, D) / HAZE);
|
|
219
|
+
// painted for distance Z, seen from D away
|
|
220
|
+
const haze = (Z, D) => {
|
|
221
|
+
const h0 = Math.min(0.85, hz(Z)), h1 = hz(D), k = Math.min(3, (1 - h1) / (1 - h0));
|
|
222
|
+
return [k, h1 - h0 * k];
|
|
223
|
+
};
|
|
224
|
+
function flush() {
|
|
225
|
+
if (!n) return;
|
|
226
|
+
gl.useProgram(prog);
|
|
227
|
+
gl.bindVertexArray(vao);
|
|
228
|
+
gl.uniform2f(su.canvas, CW, CH);
|
|
229
|
+
gl.uniform2f(su.off, OX, OY);
|
|
230
|
+
gl.uniform3f(su.fog, ...FOG);
|
|
231
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
232
|
+
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);
|
|
235
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
|
236
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, 0, data, 0, n * 4 * STRIDE);
|
|
237
|
+
gl.drawElements(gl.TRIANGLES, n * 6, gl.UNSIGNED_SHORT, 0);
|
|
238
|
+
n = 0;
|
|
239
|
+
}
|
|
240
|
+
// one textured quad: the rect (x, y, w, h) transformed by m, uv rect, tint, alpha.
|
|
241
|
+
// Colours are premultiplied; additive quads get alpha 0 so they only add light.
|
|
242
|
+
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;
|
|
245
|
+
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
|
+
const ax = m[0] * w, ay = m[1] * w, bx = m[2] * h, by = m[3] * h;
|
|
248
|
+
const ox = m[0] * x + m[2] * y + m[4], oy = m[1] * x + m[3] * y + m[5];
|
|
249
|
+
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]);
|
|
255
|
+
n++;
|
|
256
|
+
}
|
|
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) {
|
|
259
|
+
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
|
+
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; };
|
|
263
|
+
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
|
+
n++;
|
|
265
|
+
}
|
|
266
|
+
// a textured quad with four free corners, each [x, y, u, v]
|
|
267
|
+
function quadAt(tex, A, B, C_, D) {
|
|
268
|
+
if (tex !== curTex || curAdd || n >= QUADS) { flush(); curTex = tex; curAdd = false; }
|
|
269
|
+
let k = n * 4 * STRIDE;
|
|
270
|
+
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];
|
|
273
|
+
k += STRIDE;
|
|
274
|
+
}
|
|
275
|
+
n++;
|
|
276
|
+
}
|
|
277
|
+
function sprite(tex, m, cx, cy, w, h, rot, c, a, add) {
|
|
278
|
+
rect(tex, rot ? mul(m, around(cx, cy, rot, 1, 1)) : m, cx - w / 2, cy - h / 2, w, h, c, a, add);
|
|
279
|
+
}
|
|
280
|
+
// an opaque band across all that is seen (y0..y1, frame px) drawn with program p (its
|
|
281
|
+
// u_canvas, u_off set)
|
|
282
|
+
function band(p, y0, y1) {
|
|
283
|
+
flush();
|
|
284
|
+
gl.useProgram(p);
|
|
285
|
+
gl.bindVertexArray(qvao);
|
|
286
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, qvbo);
|
|
287
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([VX0, y0, VX1, y0, VX1, y1, VX0, y0, VX1, y1, VX0, y1]), gl.STREAM_DRAW);
|
|
288
|
+
gl.disable(gl.BLEND);
|
|
289
|
+
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
290
|
+
gl.enable(gl.BLEND);
|
|
291
|
+
curTex = null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── camera ───────────────────────────────────────────────────────────
|
|
295
|
+
let YH = 0.6; // horizon of the current scene, in screen heights
|
|
296
|
+
const camMatrix = (cam, Z) => I.view(cam, Z, W, H, YH);
|
|
297
|
+
// where an element's painting goes from camera c: { m, D } (D: its distance along the gaze),
|
|
298
|
+
// null behind the eye
|
|
299
|
+
const matOf = (ob, c) => (ROUND ? I.billboard(c, ob.p, ob.x, ob.y, W, H, YH) : { m: camMatrix(c, ob.Z), D: ob.Z - c.z });
|
|
300
|
+
// what part of a depth plane is seen, in its own (unscaled) coordinates
|
|
301
|
+
const visible = m => ({ x: (VX0 - m[4]) / m[0], y: (VY0 - m[5]) / m[3], w: (VX1 - VX0) / m[0], h: (VY1 - VY0) / m[3] });
|
|
302
|
+
|
|
303
|
+
// ── landscape layers ─────────────────────────────────────────────────
|
|
304
|
+
// A layer is one colour. Per vertical strip it is empty above `tops`, solid below
|
|
305
|
+
// `solids`: the body is a flat fill, and only the band between (ridges, roofs, trees)
|
|
306
|
+
// needs a texture. Close up, that band is shown from sharp tiles painted at the zoom
|
|
307
|
+
// level (TILE px square, level n = scale LEVEL^n), like a map; a tile that isn't ready
|
|
308
|
+
// yet falls back to a neighbouring level, then to the scene's base texture.
|
|
309
|
+
const TILE = 512, PAD = 1, LEVEL = 1.5, MAX_TILES = 160, TILES_PER_FRAME = 6;
|
|
310
|
+
const levelFor = s => (s <= 1.05 ? 0 : Math.min(7, Math.ceil(Math.log(s) / Math.log(LEVEL) - 1e-6)));
|
|
311
|
+
const scratch = I.layer(TILE + 2 * PAD, TILE + 2 * PAD), pool = [];
|
|
312
|
+
let tileCount = 0, frameNo = 0;
|
|
313
|
+
let BYID = new Map(); // the scene's elements by id (this frame's)
|
|
314
|
+
function tileTexture() {
|
|
315
|
+
if (pool.length) return pool.pop();
|
|
316
|
+
const t = gl.createTexture();
|
|
317
|
+
gl.bindTexture(gl.TEXTURE_2D, t);
|
|
318
|
+
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, TILE + 2 * PAD, TILE + 2 * PAD);
|
|
319
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
320
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
321
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
322
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
323
|
+
return t;
|
|
324
|
+
}
|
|
325
|
+
function freeTile(tile) {
|
|
326
|
+
tileCount--;
|
|
327
|
+
if (pool.length < 16) pool.push(tile.tex); else gl.deleteTexture(tile.tex);
|
|
328
|
+
}
|
|
329
|
+
const TUV = [PAD / (TILE + 2 * PAD), PAD / (TILE + 2 * PAD), 1 - PAD / (TILE + 2 * PAD), 1 - PAD / (TILE + 2 * PAD)];
|
|
330
|
+
const tileSize = n => TILE / Math.pow(LEVEL, n);
|
|
331
|
+
// the band's vertical extent over layer x range [xa, xb]
|
|
332
|
+
function bandOver(l, xa, xb) {
|
|
333
|
+
let top = Infinity, bot = -Infinity;
|
|
334
|
+
for (let i = Math.max(0, Math.floor((xa - l.x0) / l.stripW)); i <= Math.min(l.tops.length - 1, Math.floor((xb - l.x0) / l.stripW)); i++) {
|
|
335
|
+
if (l.solids[i] > l.tops[i]) { top = Math.min(top, l.tops[i]); bot = Math.max(bot, l.solids[i]); }
|
|
336
|
+
}
|
|
337
|
+
return [top, bot];
|
|
338
|
+
}
|
|
339
|
+
// tile cells at level n that cover the band within region v (layer coordinates)
|
|
340
|
+
function cells(l, n, v) {
|
|
341
|
+
const ts = tileSize(n), out = [];
|
|
342
|
+
for (let tx = Math.floor(v.x / ts); tx * ts < v.x + v.w; tx++) {
|
|
343
|
+
const [top, bot] = bandOver(l, tx * ts, (tx + 1) * ts);
|
|
344
|
+
if (!(bot > top)) continue;
|
|
345
|
+
for (let ty = Math.floor(Math.max(v.y, top) / ts); ty * ts < Math.min(v.y + v.h, bot); ty++) out.push({ tx, ty, x: tx * ts, y: ty * ts, s: ts });
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
function paintTile(l, n, c) {
|
|
350
|
+
const k = Math.pow(LEVEL, n);
|
|
351
|
+
I.staticLayer(l.L, SW, H, l.seed, { x: c.x - PAD / k, y: c.y - PAD / k, w: (TILE + 2 * PAD) / k, h: (TILE + 2 * PAD) / k, k, canvas: scratch });
|
|
352
|
+
const tex = tileTexture();
|
|
353
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
354
|
+
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, scratch);
|
|
355
|
+
l.tiles.set(`${n}:${c.tx}:${c.ty}`, { tex, used: frameNo, n });
|
|
356
|
+
tileCount++;
|
|
357
|
+
}
|
|
358
|
+
// best available texture for a cell: its own level, else a neighbour level covering it
|
|
359
|
+
function tileFor(l, n, c) {
|
|
360
|
+
for (const m of [n, n - 1, n + 1]) {
|
|
361
|
+
if (m < 1) continue;
|
|
362
|
+
const ts = tileSize(m), tx = Math.floor((c.x + c.s / 2) / ts), ty = Math.floor((c.y + c.s / 2) / ts);
|
|
363
|
+
const t = l.tiles.get(`${m}:${tx}:${ty}`);
|
|
364
|
+
if (t && (m === n || (tx * ts <= c.x + 0.01 && ty * ts <= c.y + 0.01 && (tx + 1) * ts >= c.x + c.s - 0.01 && (ty + 1) * ts >= c.y + c.s - 0.01))) {
|
|
365
|
+
t.used = frameNo;
|
|
366
|
+
const ox = (c.x - tx * ts) / ts, oy = (c.y - ty * ts) / ts, f = c.s / ts, span = TUV[2] - TUV[0];
|
|
367
|
+
return { tex: t.tex, uv: [TUV[0] + ox * span, TUV[1] + oy * span, TUV[0] + (ox + f) * span, TUV[1] + (oy + f) * span] };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
function drawLayer(l, m, cam) {
|
|
373
|
+
const r = l.region, bottom = r.y + r.h, c = col(l.L.color);
|
|
374
|
+
// flat body, strip by strip
|
|
375
|
+
for (let i = 0; i < l.tops.length; i++) {
|
|
376
|
+
const xa = l.x0 + i * l.stripW, xb = xa + l.stripW;
|
|
377
|
+
if (m[0] * xb + m[4] < VX0 || m[0] * xa + m[4] > VX1) continue;
|
|
378
|
+
rect(T.white, m, xa, l.solids[i], l.stripW, bottom + 50 * H - l.solids[i], c);
|
|
379
|
+
}
|
|
380
|
+
const n = levelFor(m[0]);
|
|
381
|
+
if (n === 0) {
|
|
382
|
+
for (let i = 0; i < l.tops.length; i++) {
|
|
383
|
+
const xa = l.x0 + i * l.stripW, xb = xa + l.stripW, ya = l.tops[i], yb = l.solids[i];
|
|
384
|
+
if (yb <= ya || m[0] * xb + m[4] < VX0 || m[0] * xa + m[4] > VX1) continue;
|
|
385
|
+
rect(l.tex, m, xa, ya, xb - xa, yb - ya, WHITE, 1, false, [(xa - r.x) / r.w, (ya - r.y) / r.h, (xb - r.x) / r.w, (yb - r.y) / r.h]);
|
|
386
|
+
}
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
for (const cell of cells(l, n, visible(m))) {
|
|
390
|
+
const t = tileFor(l, n, cell);
|
|
391
|
+
if (t) rect(t.tex, m, cell.x, cell.y, cell.s, cell.s, WHITE, 1, false, t.uv);
|
|
392
|
+
else rect(l.tex, m, cell.x, cell.y, cell.s, cell.s, WHITE, 1, false, [(cell.x - r.x) / r.w, (cell.y - r.y) / r.h, (cell.x + cell.s - r.x) / r.w, (cell.y + cell.s - r.y) / r.h]);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── build: paint everything once ─────────────────────────────────────
|
|
397
|
+
// w × h: the frame; out = { x, y, cw, ch }: where it starts in the canvas and the canvas (px)
|
|
398
|
+
function build(spec, w, h, prev, out) {
|
|
399
|
+
if (prev) {
|
|
400
|
+
for (const t of prev.textures) gl.deleteTexture(t);
|
|
401
|
+
dropTarget(prev.scene); dropTarget(prev.copy);
|
|
402
|
+
for (const l of prev.layers) if (l.tiles) for (const t of l.tiles.values()) freeTile(t);
|
|
403
|
+
}
|
|
404
|
+
W = w; H = h; SW = I.STAGE * h; ROUND = I.camSetup(spec).round;
|
|
405
|
+
OX = out.x; OY = out.y; CW = out.cw; CH = out.ch; VX0 = -OX; VY0 = -OY; VX1 = CW - OX; VY1 = CH - OY;
|
|
406
|
+
const textures = new Set(), tex = c => { const t = texture(c); textures.add(t); return t; };
|
|
407
|
+
const u = H / 900, seed = I.hash(JSON.stringify(spec.layers || []) + (spec.title || '')), rnd = I.seeded(seed);
|
|
408
|
+
const x0 = -I.EXT * SW, span = (1 + 2 * I.EXT) * SW, dz = I.depths(spec);
|
|
409
|
+
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;
|
|
412
|
+
|
|
413
|
+
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
|
+
const starCount = Math.round((spec.stars?.density ?? 0) * span * H / 2500 / (u * u));
|
|
415
|
+
const stars = Array.from({ length: starCount }, () => ({ x: x0 + rnd() * span, y: -0.45 * H + Math.pow(rnd(), 1.4) * H * 1.15, r: (0.4 + Math.pow(rnd(), 4) * 1.6) * u, p: rnd() * 7, f: 0.4 + rnd() * 2 }));
|
|
416
|
+
// the sky plane zooms at most ZOOM_MAX^0.32 ≈ 1.4×: paint celestials and birds that much finer
|
|
417
|
+
const SS = 1.5;
|
|
418
|
+
const celestials = (spec.celestials || []).map(c => {
|
|
419
|
+
const r = c.size * H / 2, R = r * 9, size = Math.max(4, Math.ceil(2 * R));
|
|
420
|
+
const canvas = paint(size * SS, size * SS, (g, cw) => {
|
|
421
|
+
g.scale(cw / size, cw / size);
|
|
422
|
+
const cx = size / 2, cy = size / 2, cc = I.rgb(c.color);
|
|
423
|
+
g.globalAlpha = Math.min(1, 0.35 * (c.glow ?? 0.5));
|
|
424
|
+
g.drawImage(I.glowSprite(cc), 0, 0, size, size);
|
|
425
|
+
g.globalAlpha = 1;
|
|
426
|
+
g.fillStyle = c.color; g.beginPath(); g.arc(cx, cy, r, 0, 7); g.fill();
|
|
427
|
+
if (c.kind === 'moon') { g.fillStyle = 'rgba(0,0,0,0.07)'; for (const [dx, dy, rr] of [[-0.3, -0.2, 0.25], [0.28, 0.15, 0.17], [-0.05, 0.4, 0.13]]) { g.beginPath(); g.arc(cx + dx * r, cy + dy * r, rr * r, 0, 7); g.fill(); } }
|
|
428
|
+
if (c.kind === 'planet') { g.strokeStyle = `rgba(${cc},0.6)`; g.lineWidth = r * 0.08; g.beginPath(); g.ellipse(cx, cy, r * 1.8, r * 0.4, -0.3, 0, 7); g.stroke(); }
|
|
429
|
+
});
|
|
430
|
+
return { tex: tex(canvas), x: c.x * SW - size / 2, y: c.y * H - size / 2, size };
|
|
431
|
+
});
|
|
432
|
+
const layers = (spec.layers || []).map((L, i) => {
|
|
433
|
+
const Z = dz.Z[i];
|
|
434
|
+
if (L.kind === 'clouds') {
|
|
435
|
+
const cc = I.rgb(L.color);
|
|
436
|
+
const clouds = Array.from({ length: Math.round((3 + (L.density ?? 0.5) * 9) * (1 + 2 * I.EXT)) }, () => {
|
|
437
|
+
const cw = SW * (0.12 + rnd() * 0.2), ch = cw * 0.35;
|
|
438
|
+
const canvas = paint(cw, ch, (g) => {
|
|
439
|
+
for (let k = 0; k < 7; k++) {
|
|
440
|
+
const gx = cw * (0.2 + rnd() * 0.6), gy = ch * (0.45 + rnd() * 0.25), gr = ch * (0.35 + rnd() * 0.3);
|
|
441
|
+
g.globalAlpha = 0.55; g.drawImage(I.glowSprite(cc), gx - gr, gy - gr, 2 * gr, 2 * gr);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
return { tex: tex(canvas), w: cw, h: ch, x: rnd() * span, y: L.y * H + (rnd() - 0.5) * (L.height ?? 0.1) * H, sp: (4 + rnd() * 8) * u };
|
|
445
|
+
});
|
|
446
|
+
return { L, Z, clouds };
|
|
447
|
+
}
|
|
448
|
+
if (L.kind === 'fog') return { L, Z, c: col(L.color), blobs: Array.from({ length: 10 }, () => ({ x: rnd() * span, dy: (rnd() - 0.5) * (L.height ?? 0.08) * H, rx: SW * (0.2 + rnd() * 0.25), ry: H * (0.03 + rnd() * 0.04), sp: (5 + rnd() * 10) * u, a: 0.06 + rnd() * 0.06 })) };
|
|
449
|
+
if (L.kind === 'water') return { L, Z, c: col(L.color) };
|
|
450
|
+
const lseed = seed + i * 101, st = I.staticLayer(L, SW, H, lseed);
|
|
451
|
+
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
|
+
});
|
|
453
|
+
const objects = (spec.objects || []).map(o0 => {
|
|
454
|
+
if (o0.plane) return planeOf(o0, dz, tex);
|
|
455
|
+
// looking around, the sky is the full circle, 2π stage heights wide (engine.js)
|
|
456
|
+
const o = { ...o0, x: dz.objectX(o0), y: dz.objectY(o0), size: dz.objectSize(o0), _yh: dz.yh, _sw: ROUND && o0.sky ? I.TAU * H : SW }, Z = dz.objectZ(o0);
|
|
457
|
+
const x = o.x * SW, y = o.y * H, s = Math.max(2, o.size * H);
|
|
458
|
+
if (o.kind === 'bird') {
|
|
459
|
+
const canvas = paint(s * 1.2 * SS, s * 0.6 * SS, (g) => {
|
|
460
|
+
g.scale(SS, SS);
|
|
461
|
+
g.strokeStyle = o.color; g.lineCap = 'round'; g.lineWidth = Math.max(1.5, s * 0.08);
|
|
462
|
+
const cx = s * 0.6, cy = s * 0.35;
|
|
463
|
+
g.beginPath(); g.moveTo(cx - s * 0.5, cy - s * 0.1); g.quadraticCurveTo(cx - s * 0.2, cy - s * 0.15, cx, cy);
|
|
464
|
+
g.quadraticCurveTo(cx + s * 0.2, cy - s * 0.15, cx + s * 0.5, cy - s * 0.1); g.stroke();
|
|
465
|
+
});
|
|
466
|
+
return { o, Z, x, y, s, tex: tex(canvas), w: s * 1.2, h: s * 0.6, lights: [] };
|
|
467
|
+
}
|
|
468
|
+
// a custom element whose code reads the time is repainted live every frame (one reading the
|
|
469
|
+
// scene's state, when the state changes)
|
|
470
|
+
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;
|
|
472
|
+
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 ? dz.objectAt(o0) : null,
|
|
476
|
+
live, ground: !!o.ground, from: o0.place ? o0 : null,
|
|
477
|
+
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 })),
|
|
479
|
+
};
|
|
480
|
+
});
|
|
481
|
+
// particles fall over all that is seen: in the canvas's pixels
|
|
482
|
+
const particles = (spec.particles || []).map(p => ({ p, c: col(p.color || '#ffffff'), list: I.makeParticles(p, CW, CH, rnd) }));
|
|
483
|
+
// 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 };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Paints an object at t = 0 — the region (bx, by, bw, bh) of it — into a canvas at scale
|
|
489
|
+
// 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 = [];
|
|
492
|
+
const draw = (g) => {
|
|
493
|
+
g.setTransform(k, 0, 0, k, -bx * k, -by * k);
|
|
494
|
+
I.drawObject(g, o, o._sw || SW, H, t, lights);
|
|
495
|
+
for (const l of lights) {
|
|
496
|
+
if (l.rect) { g.fillStyle = `rgba(${l.col},${Math.min(1, l.a * 2.2)})`; g.fillRect(...l.rect); }
|
|
497
|
+
if (l.cone) {
|
|
498
|
+
const gr = g.createLinearGradient(0, l.y, 0, l.cone.y);
|
|
499
|
+
gr.addColorStop(0, `rgba(${l.col},${l.a * 0.35})`); gr.addColorStop(1, `rgba(${l.col},0)`);
|
|
500
|
+
g.fillStyle = gr; g.globalCompositeOperation = 'lighter';
|
|
501
|
+
g.beginPath(); g.moveTo(l.x - l.dot, l.y); g.lineTo(l.x + l.dot, l.y); g.lineTo(l.x + l.cone.w, l.cone.y); g.lineTo(l.x - l.cone.w, l.cone.y); g.fill();
|
|
502
|
+
g.globalCompositeOperation = 'source-over';
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
let canvas = into;
|
|
507
|
+
if (into) {
|
|
508
|
+
const w = Math.max(1, Math.round(bw * k)), h = Math.max(1, Math.round(bh * k));
|
|
509
|
+
if (into.width !== w || into.height !== h) { into.width = w; into.height = h; }
|
|
510
|
+
const g = into.getContext('2d');
|
|
511
|
+
g.setTransform(1, 0, 0, 1, 0, 0);
|
|
512
|
+
g.clearRect(0, 0, w, h);
|
|
513
|
+
draw(g);
|
|
514
|
+
} else canvas = paint(bw * k, bh * k, draw);
|
|
515
|
+
return { canvas, lights };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ── sharp close-ups ──────────────────────────────────────────────────
|
|
519
|
+
const MAX_SIDE = 8192;
|
|
520
|
+
const intersect = (a, b) => {
|
|
521
|
+
const x = Math.max(a.x, b.x), y = Math.max(a.y, b.y), w = Math.min(a.x + a.w, b.x + b.w) - x, h = Math.min(a.y + a.h, b.y + b.h) - y;
|
|
522
|
+
return w > 0 && h > 0 ? { x, y, w, h } : null;
|
|
523
|
+
};
|
|
524
|
+
const covers = (r, v) => r.x <= v.x + 0.5 && r.y <= v.y + 0.5 && r.x + r.w >= v.x + v.w - 0.5 && r.y + r.h >= v.y + v.h - 0.5;
|
|
525
|
+
function setDetail(S, owner, det) {
|
|
526
|
+
if (owner.detail) for (const t of owner.detail.frames) { gl.deleteTexture(t); S.textures.delete(t); }
|
|
527
|
+
owner.detail = det;
|
|
528
|
+
if (det) for (const t of det.frames) S.textures.add(t);
|
|
529
|
+
}
|
|
530
|
+
// Each frame, paint a few missing tiles: first for where the camera is heading (`goal`),
|
|
531
|
+
// so the close-up is sharp on arrival, then for where it is now; nearest layer and
|
|
532
|
+
// screen centre first. Objects are small: re-painted whole at the needed scale.
|
|
533
|
+
function refine(S, cam, goal) {
|
|
534
|
+
let budget = TILES_PER_FRAME;
|
|
535
|
+
for (const c of [goal, cam]) {
|
|
536
|
+
for (const l of [...S.layers].reverse()) {
|
|
537
|
+
if (!l.tiles) continue;
|
|
538
|
+
if (l.Z - c.z < 0.05) continue;
|
|
539
|
+
const n = levelFor(camMatrix(c, l.Z)[0]);
|
|
540
|
+
if (n === 0) continue;
|
|
541
|
+
const v = visible(camMatrix(c, l.Z)), cx = v.x + v.w / 2, cy = v.y + v.h / 2;
|
|
542
|
+
const todo = cells(l, n, v).filter(cell => !l.tiles.has(`${n}:${cell.tx}:${cell.ty}`));
|
|
543
|
+
todo.sort((a, b) => Math.hypot(a.x - cx, a.y - cy) - Math.hypot(b.x - cx, b.y - cy));
|
|
544
|
+
for (const cell of todo) {
|
|
545
|
+
if (budget-- <= 0) return;
|
|
546
|
+
paintTile(l, n, cell);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// objects: re-painted at the needed scale, only the part on screen (a tall tower
|
|
550
|
+
// close up would otherwise be a huge canvas)
|
|
551
|
+
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;
|
|
553
|
+
const at = matOf(ob, c);
|
|
554
|
+
if (!at || at.D < 0.3) continue;
|
|
555
|
+
const k = Math.pow(LEVEL, levelFor(at.m[0]));
|
|
556
|
+
if (k <= 1) { if (ob.detail && c === cam) setDetail(S, ob, null); continue; }
|
|
557
|
+
const v = visible(at.m), box = { x: ob.bx, y: ob.by, w: ob.bw, h: ob.bh };
|
|
558
|
+
const need = intersect(box, v);
|
|
559
|
+
if (!need || (ob.detail && ob.detail.k >= k && covers(ob.detail.region, need))) continue;
|
|
560
|
+
const r = intersect(box, { x: v.x - v.w * 0.15, y: v.y - v.h * 0.15, w: v.w * 1.3, h: v.h * 1.3 });
|
|
561
|
+
// cost in tiles' worth of pixels; one big job may run alone in a frame
|
|
562
|
+
const cost = Math.ceil(r.w * r.h * k * k / (TILE * TILE)) * ob.frames.length;
|
|
563
|
+
if (cost > budget && budget < TILES_PER_FRAME) return;
|
|
564
|
+
budget -= cost;
|
|
565
|
+
const kk = Math.min(k, MAX_SIDE / r.w, MAX_SIDE / r.h);
|
|
566
|
+
const n = ob.frames.length;
|
|
567
|
+
setDetail(S, ob, { frames: ob.frames.map((_, f) => texture(paintObject(ob.o, r.x, r.y, r.w, r.h, kk, f / n * ob.period).canvas)), k, region: r });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// keep GPU memory bounded: drop the least recently drawn tiles
|
|
572
|
+
function evict(S) {
|
|
573
|
+
if (tileCount <= MAX_TILES) return;
|
|
574
|
+
const all = [];
|
|
575
|
+
for (const l of S.layers) if (l.tiles) for (const [key, t] of l.tiles) all.push([t.used, l, key, t]);
|
|
576
|
+
all.sort((a, b) => a[0] - b[0]);
|
|
577
|
+
for (const [used, l, key, t] of all.slice(0, tileCount - MAX_TILES)) {
|
|
578
|
+
if (used === frameNo) break;
|
|
579
|
+
l.tiles.delete(key); freeTile(t);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function drawWater(S, l, wy, t) {
|
|
584
|
+
flush();
|
|
585
|
+
if (wy >= VY1) return;
|
|
586
|
+
// copy what is drawn so far, then shade the water below the horizon from that copy
|
|
587
|
+
gl.bindFramebuffer(gl.READ_FRAMEBUFFER, S.scene.fbo);
|
|
588
|
+
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, S.copy.fbo);
|
|
589
|
+
gl.blitFramebuffer(0, 0, CW, CH, 0, 0, CW, CH, gl.COLOR_BUFFER_BIT, gl.NEAREST);
|
|
590
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, S.scene.fbo);
|
|
591
|
+
gl.useProgram(wprog);
|
|
592
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
593
|
+
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);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const wrapX = (x, S, w) => ((x % (S.span + w)) + S.span + w) % (S.span + w) + S.x0 - w;
|
|
608
|
+
const WINDOW = col('#ffd08a'), DOT = col('255,245,220');
|
|
609
|
+
|
|
610
|
+
// how finely a live element's region r is repainted: the scale it is seen at, up to ~4 Mpx
|
|
611
|
+
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)));
|
|
613
|
+
|
|
614
|
+
// A ground plane (water, fields, a road) is painted as seen from the start. Each row of it
|
|
615
|
+
// lies at a depth — a row h below the horizon is 1/h eye heights away — so as you walk, the
|
|
616
|
+
// rows are laid out again in perspective: drawn as thin strips, each with its own haze.
|
|
617
|
+
function drawGround(ob, cam, t) {
|
|
618
|
+
let tx = ob.tex;
|
|
619
|
+
if (ob.live) {
|
|
620
|
+
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);
|
|
622
|
+
tx = ob.live.tex;
|
|
623
|
+
}
|
|
624
|
+
const yhH = YH * H, top = Math.max(ob.by, yhH + 0.5), bot = ob.by + ob.bh, N = 120, look = -cam.y * H;
|
|
625
|
+
const row = y => {
|
|
626
|
+
const Z0 = H / (y - yhH), D = Z0 - cam.z;
|
|
627
|
+
if (D <= 0.05) return null;
|
|
628
|
+
const s = Z0 / D;
|
|
629
|
+
return { Z0, s, e: (W - s * SW) / 2 - H * cam.x / D, y: yhH + (y - yhH) * s + look + H * ((cam.h ?? 1) - 1) / D };
|
|
630
|
+
};
|
|
631
|
+
let a = row(top);
|
|
632
|
+
for (let i = 1; i <= N && a; i++) {
|
|
633
|
+
const y1 = top + (bot - top) * (i / N) ** 1.6, b = row(y1); // finer near the horizon
|
|
634
|
+
if (!b) break;
|
|
635
|
+
const y0 = top + (bot - top) * ((i - 1) / N) ** 1.6, xl = ob.bx, xr = ob.bx + ob.bw;
|
|
636
|
+
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]);
|
|
639
|
+
}
|
|
640
|
+
if (a.y > VY1) break;
|
|
641
|
+
a = b;
|
|
642
|
+
}
|
|
643
|
+
HZ = NOHAZE;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ── horizontal planes ────────────────────────────────────────────────
|
|
647
|
+
// A plane lies flat — a floor, a pier, a table top, a chessboard, a patch of water. The
|
|
648
|
+
// artist paints it from above, in metres, origin at its centre (x to the right, y toward
|
|
649
|
+
// the viewer); it lies `distance` m away (its centre), at `elevation` m, `width` × `depth` m,
|
|
650
|
+
// turned `rotation` degrees about the vertical. It is projected truly for wherever the
|
|
651
|
+
// camera is: drawn as a grid of small cells, each from its own projected corners, with the
|
|
652
|
+
// haze of its own distance. It is drawn at its far edge, before whatever stands on it.
|
|
653
|
+
// World units here are eye heights, as everywhere in the camera: a point (X, Y, Z) is seen
|
|
654
|
+
// at x = W/2 + H·(X − cam.x)/(Z − cam.z), y = yh·H + H·(1 − Y)/(Z − cam.z) − cam.y·H.
|
|
655
|
+
// 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.
|
|
658
|
+
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;
|
|
667
|
+
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);
|
|
670
|
+
}
|
|
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;
|
|
675
|
+
flush(); // quads already batched must be drawn with their own texture first
|
|
676
|
+
gl.bindTexture(gl.TEXTURE_2D, ob.live.tex);
|
|
677
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, p.canvas);
|
|
678
|
+
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 }));
|
|
682
|
+
}
|
|
683
|
+
const PLANE_TEX = 2048;
|
|
684
|
+
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;
|
|
687
|
+
// 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 };
|
|
689
|
+
const k = PLANE_TEX / (Math.max(w, d) * H), bx = -w / 2 * H, by = -d / 2 * H, bw = w * H, bh = d * H;
|
|
690
|
+
const r0 = paintObject(o, bx, by, bw, bh, k, 0);
|
|
691
|
+
const live = liveOf(o, 0, tex);
|
|
692
|
+
const c = Math.abs(Math.cos(rot)), s = Math.abs(Math.sin(rot));
|
|
693
|
+
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,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
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);
|
|
736
|
+
if (pt.length === 3) quadAt(tex, pt[0], pt[1], pt[2], pt[2]);
|
|
737
|
+
else quadAt(tex, pt[0], pt[1], pt[2], pt[3]);
|
|
738
|
+
if (pt.length === 5) quadAt(tex, pt[0], pt[3], pt[4], pt[4]);
|
|
739
|
+
};
|
|
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;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function drawPlane(ob, cam, t) {
|
|
745
|
+
let tx = ob.tex;
|
|
746
|
+
if (ob.live) {
|
|
747
|
+
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);
|
|
749
|
+
tx = ob.live.tex;
|
|
750
|
+
}
|
|
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]; });
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// the texture an element is shown with now: its live painting (repainted when due), else its
|
|
761
|
+
// frame of the loop — the sharp close-up when that covers what is seen of it
|
|
762
|
+
function textureOf(ob, m, t) {
|
|
763
|
+
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
|
+
const seen = ob.live && m ? intersect(whole, visible(m)) : null;
|
|
765
|
+
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;
|
|
770
|
+
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);
|
|
779
|
+
return { tx: ob.live.tex, r: ob.live.r, lights: ob.live.lights };
|
|
780
|
+
}
|
|
781
|
+
const det = ob.detail, onScreen = det && m && intersect(whole, visible(m));
|
|
782
|
+
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 };
|
|
784
|
+
}
|
|
785
|
+
|
|
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;
|
|
795
|
+
const { o, x, y, s } = ob;
|
|
796
|
+
// a platformer's hero: moved and turned from where it is painted
|
|
797
|
+
const m = cam.hero && o.id === cam.hero.id ? mul(at.m, I.heroMatrix(cam.hero, x, s / (o.height || 2))) : at.m;
|
|
798
|
+
if (o.kind === 'bird') {
|
|
799
|
+
// across the widest frame
|
|
800
|
+
const span = I.WIDEST * H + 4 * s, bx = ((x + t * s * 1.5 * (o.flip ? -1 : 1)) % span + span) % span + (SW - I.WIDEST * H) / 2 - 2 * s;
|
|
801
|
+
const by = y + Math.sin(t * 0.7 + x) * s * 0.4;
|
|
802
|
+
// wings flap by squashing the painted bird vertically around its body
|
|
803
|
+
rect(ob.tex, mul(m, [1, 0, 0, 0.3 + 0.7 * Math.sin(t * 8 + x), bx, by]), -ob.w / 2, -ob.h * 0.58, ob.w, ob.h, WHITE, fade);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
// gentle per-kind motion, applied to the pre-painted object around its base
|
|
807
|
+
let a = ID;
|
|
808
|
+
if (o.kind === 'tree') a = around(x, y, Math.sin(t * 0.8 + x) * 0.015, 1, 1);
|
|
809
|
+
else if (o.kind === 'flower') a = around(x, y, Math.sin(t * 1.4 + x) * 0.06, 1, 1);
|
|
810
|
+
else if (o.kind === 'lantern') a = around(x, y - s * 0.6, Math.sin(t * 1.2 + x) * 0.12, 1, 1);
|
|
811
|
+
else if (o.kind === 'boat') a = around(x, y, 0, 1, 1, 0, Math.sin(t * 1.1 + x) * s * 0.03);
|
|
812
|
+
else if (o.kind === 'figure') a = around(x, y, 0, 1, 1 + Math.sin(t * 1.3) * 0.008);
|
|
813
|
+
else if ((o.kind === 'custom' || !o.kind) && (o.sway || o.bob)) {
|
|
814
|
+
// custom motion hints: sway (degrees, about the base), bob (metres, up and down)
|
|
815
|
+
const ppm = s / (o.height || 2);
|
|
816
|
+
a = around(x, y, Math.sin(t * 0.9 + x) * (o.sway || 0) * Math.PI / 180, 1, 1, 0, Math.sin(t * 1.1 + x) * (o.bob || 0) * ppm);
|
|
817
|
+
}
|
|
818
|
+
// the elements its code reads (`after`: where another figure's hand is now, through a module)
|
|
819
|
+
// are painted first this frame
|
|
820
|
+
for (const id of Array.isArray(o.after) ? o.after : []) {
|
|
821
|
+
const other = BYID.get(id), oat = other?.live && matOf(other, cam);
|
|
822
|
+
if (oat) textureOf(other, oat.m, t);
|
|
823
|
+
}
|
|
824
|
+
const ma = a === ID ? m : mul(m, a), { tx, r, lights } = textureOf(ob, m, t);
|
|
825
|
+
// 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) {
|
|
828
|
+
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);
|
|
830
|
+
}
|
|
831
|
+
rect(tx, ma, r.x, r.y, r.w, r.h, WHITE, fade);
|
|
832
|
+
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
|
+
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);
|
|
838
|
+
if (lt.dot) sprite(T.glow, m, lx, ly, 6 * lt.dot, 6 * lt.dot, 0, DOT, 0.9 * fade, true);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Looking around, an element with `facing` stands turned its own way (a wall, a door, a
|
|
843
|
+
// cupboard): its painting is a vertical card in the world — the painted point (px, py) is
|
|
844
|
+
// (px − x, py − y)/ppm metres from its anchor along the card and down — projected truly,
|
|
845
|
+
// as a grid of small cells like a plane, each with the haze of its own distance.
|
|
846
|
+
function drawCard(ob, cam, t) {
|
|
847
|
+
const p = ob.p, f = ob.o.facing * Math.PI / 180, ppm = H / p.Zp; // painted px per eye height
|
|
848
|
+
const tx_ = -Math.cos(f), tz = Math.sin(f); // along the card, to the right of one facing it
|
|
849
|
+
const { tx, r, lights } = textureOf(ob, null, t);
|
|
850
|
+
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));
|
|
852
|
+
const at = (px, py) => I.project(cam, ...world(px, py), W, H, YH);
|
|
853
|
+
for (const lt of lights) {
|
|
854
|
+
const q = at(lt.x, lt.y);
|
|
855
|
+
if (!q) continue;
|
|
856
|
+
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
|
+
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
|
+
}
|
|
859
|
+
}
|
|
860
|
+
// The sky all around: its painting is the full circle (2π stage heights, straight ahead in
|
|
861
|
+
// the middle); each narrow column of the frame shows the bearing it looks at.
|
|
862
|
+
function drawSkyAround(ob, cam, t) {
|
|
863
|
+
const { tx } = textureOf(ob, null, t), C = 48, top = ob.by - cam.y * H;
|
|
864
|
+
for (let i = 0; i < C; i++) {
|
|
865
|
+
const xa = VX0 + (VX1 - VX0) * i / C, xb = VX0 + (VX1 - VX0) * (i + 1) / C;
|
|
866
|
+
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;
|
|
867
|
+
const base = Math.floor(ua), u0 = ua - base, u1 = ub - base;
|
|
868
|
+
if (u1 <= 1) { rect(tx, ID, xa, top, xb - xa, ob.bh, WHITE, 1, false, [u0, 0, u1, 1]); continue; }
|
|
869
|
+
// the column crosses the seam behind you: both sides of it
|
|
870
|
+
const xm = xa + (xb - xa) * (1 - u0) / (u1 - u0);
|
|
871
|
+
rect(tx, ID, xa, top, xm - xa, ob.bh, WHITE, 1, false, [u0, 0, 1, 1]);
|
|
872
|
+
rect(tx, ID, xm, top, xb - xm, ob.bh, WHITE, 1, false, [0, 0, u1 - 1, 1]);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// An element that moves through the world (its `place`: {across, distance, elevation, x} — the
|
|
877
|
+
// names of numbers in the state, metres): put where the state says now — its size, its depth,
|
|
878
|
+
// its haze and what it is drawn in front of or behind follow.
|
|
879
|
+
function placeMoving(S) {
|
|
880
|
+
const v = I.STATE.values;
|
|
881
|
+
let moved = false;
|
|
882
|
+
for (const ob of S.objects) {
|
|
883
|
+
if (!ob.from) continue;
|
|
884
|
+
const o1 = { ...ob.from };
|
|
885
|
+
for (const [f, k] of Object.entries(ob.from.place)) if (typeof k === 'string' && typeof v[k] === 'number') o1[f] = v[k];
|
|
886
|
+
const key = `${o1.x}|${o1.across}|${o1.distance}|${o1.elevation}`;
|
|
887
|
+
if (key === ob.placed) continue;
|
|
888
|
+
ob.placed = key; moved = true;
|
|
889
|
+
const o = { ...ob.o, x: S.dz.objectX(o1), y: S.dz.objectY(o1), size: S.dz.objectSize(o1) };
|
|
890
|
+
Object.assign(ob, { o, Z: S.dz.objectZ(o1), x: o.x * SW, y: o.y * H, s: Math.max(2, o.size * H) }, I.objectBox(o, SW, H));
|
|
891
|
+
if (ROUND && !o.sky) ob.p = S.dz.objectAt(o1);
|
|
892
|
+
}
|
|
893
|
+
if (!moved) return;
|
|
894
|
+
for (const item of S.order) if (item.ob) item.Z = item.ob.Z;
|
|
895
|
+
S.order.sort((a, b) => (b.Z - a.Z) || 0);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function frame(S, spec, cam, t, dt, settled, goal) {
|
|
899
|
+
frameNo++;
|
|
900
|
+
BYID = S.byId ??= new Map(S.objects.filter(x => x.o.id != null).map(x => [x.o.id, x]));
|
|
901
|
+
placeMoving(S);
|
|
902
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, S.scene.fbo);
|
|
903
|
+
gl.viewport(0, 0, CW, CH);
|
|
904
|
+
const mSky = camMatrix(cam, Infinity);
|
|
905
|
+
gl.clearColor(0, 0, 0, 0); // what nothing paints stays clear: the final pass fills it
|
|
906
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
907
|
+
if (S.sky) rect(S.sky, mSky, S.x0, -H, S.span, 3 * H, WHITE, 1, false, [0, -1, 1, 2]);
|
|
908
|
+
|
|
909
|
+
if (ROUND) drawAround(S, cam, t);
|
|
910
|
+
else drawAhead(S, spec, cam, t, mSky);
|
|
911
|
+
particlesAndPost(S, spec, cam, t, dt, goal);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// looking around: the sky, then what stands about, far to near as seen now — planes by the
|
|
915
|
+
// farthest reach of them
|
|
916
|
+
function drawAround(S, cam, t) {
|
|
917
|
+
for (const ob of S.objects) if (ob.o.sky) drawObjectAt(ob, cam, t);
|
|
918
|
+
const items = [];
|
|
919
|
+
for (const ob of S.objects) {
|
|
920
|
+
if (ob.o.sky || ob.ground) continue;
|
|
921
|
+
if (ob.plane) {
|
|
922
|
+
const P = ob.plane;
|
|
923
|
+
items.push({ ob, d: Math.hypot(P.Xc - cam.x, P.Zc - cam.z) + Math.max(P.w, P.d) / 2 });
|
|
924
|
+
} else {
|
|
925
|
+
// a card turned its own way (a wall) by its farthest reach too: what stands in front of
|
|
926
|
+
// it is drawn after it
|
|
927
|
+
const half = typeof ob.o.facing === 'number' ? ob.bw / 2 / (H / ob.p.Zp) : 0;
|
|
928
|
+
items.push({ ob, d: Math.hypot(ob.p.X - cam.x, ob.p.Z - cam.z) + half });
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
items.sort((a, b) => b.d - a.d);
|
|
932
|
+
for (const { ob } of items) {
|
|
933
|
+
HZ = ob.plane ? NOHAZE : haze(ob.p.Zp, Math.hypot(ob.p.X - cam.x, ob.p.Z - cam.z));
|
|
934
|
+
drawObjectAt(ob, cam, t);
|
|
935
|
+
HZ = NOHAZE;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function drawAhead(S, spec, cam, t, mSky) {
|
|
940
|
+
if (S.stars.length) {
|
|
941
|
+
const sc = col(spec.stars?.color || '#ffffff');
|
|
942
|
+
for (const s of S.stars) sprite(T.dot, mSky, s.x, s.y, 2.6 * s.r, 2.6 * s.r, 0, sc, 0.25 + 0.75 * Math.pow(0.5 + 0.5 * Math.sin(t * s.f + s.p), 2), false);
|
|
943
|
+
}
|
|
944
|
+
for (const c of S.celestials) rect(c.tex, mSky, c.x, c.y, c.size, c.size);
|
|
945
|
+
|
|
946
|
+
for (const item of S.order) {
|
|
947
|
+
if (item.ob) { HZ = isFinite(item.Z) ? haze(item.Z, item.Z - cam.z) : NOHAZE; drawObjectAt(item.ob, cam, t); HZ = NOHAZE; continue; }
|
|
948
|
+
const l = item.l, L = l.L;
|
|
949
|
+
if (l.Z - cam.z < 0.05) continue; // walked past it
|
|
950
|
+
const m = camMatrix(cam, l.Z);
|
|
951
|
+
if (L.kind === 'water') {
|
|
952
|
+
drawWater(S, l, Math.round(m[5] + m[3] * L.y * H), t);
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
if (l.tex) {
|
|
956
|
+
HZ = haze(l.Z, l.Z - cam.z);
|
|
957
|
+
drawLayer(l, m, cam);
|
|
958
|
+
for (const w of l.windows) if (Math.sin(t * 0.07 + w.p) > -0.85) rect(T.white, m, w.x, w.y, w.w, w.h, WINDOW, 0.55 + 0.25 * Math.sin(w.p));
|
|
959
|
+
HZ = NOHAZE;
|
|
960
|
+
} else if (L.kind === 'clouds') {
|
|
961
|
+
for (const c of l.clouds) rect(c.tex, m, wrapX(c.x + t * c.sp * (L.drift ?? 0.3) * 3, S, c.w), c.y - c.h / 2, c.w, c.h);
|
|
962
|
+
} else if (L.kind === 'fog') {
|
|
963
|
+
for (const b of l.blobs) {
|
|
964
|
+
const cx = wrapX(b.x + t * b.sp * (L.drift ?? 0.3) * 2, S, 2 * b.rx) + b.rx;
|
|
965
|
+
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);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function particlesAndPost(S, spec, cam, t, dt, goal) {
|
|
973
|
+
const u = S.u, PM = [1, 0, 0, 1, VX0, VY0]; // particles live in the canvas's pixels
|
|
974
|
+
for (const { p, c, list } of S.particles) {
|
|
975
|
+
const wind = (p.wind ?? 0) * 60 * u;
|
|
976
|
+
for (const q of list) {
|
|
977
|
+
I.moveParticle(p.kind, q, CW, CH, t, dt, u, wind);
|
|
978
|
+
const r = (1 + q.z * 3) * u;
|
|
979
|
+
switch (p.kind) {
|
|
980
|
+
case 'rain': { const len = (10 + q.z * 16) * u; rect(T.streak, PM, q.x - Math.max(1, u), q.y, 2 * Math.max(1, u), len, c, 0.28); break; }
|
|
981
|
+
case 'fireflies': sprite(T.glow, PM, q.x, q.y, 36 * u, 36 * u, 0, c, 0.6 * (0.25 + 0.75 * Math.pow(0.5 + 0.5 * Math.sin(t * 1.6 + q.ph * 3), 3)), true); break;
|
|
982
|
+
case 'embers': sprite(T.glow, PM, q.x, q.y, 16 * u, 16 * u, 0, c, 0.6 * (0.5 + 0.5 * Math.sin(t * 5 + q.ph)), true); break;
|
|
983
|
+
case 'petals': case 'leaves': {
|
|
984
|
+
const k = p.kind === 'leaves' ? 2.4 : 1.6;
|
|
985
|
+
rect(T.ellipse, mul(PM, around(q.x, q.y, t * (0.5 + q.z) + q.ph, 1, Math.abs(Math.sin(t * 2 + q.ph)) * 0.8 + 0.2)), q.x - r * k, q.y - r, 2 * r * k, 2 * r, c, 0.85);
|
|
986
|
+
break;
|
|
987
|
+
}
|
|
988
|
+
case 'bubbles': sprite(T.ring, PM, q.x, q.y, 3.4 * r, 3.4 * r, 0, c, 0.5, false); break;
|
|
989
|
+
case 'dust': sprite(T.dot, PM, q.x, q.y, r * 1.2, r * 1.2, 0, c, 0.35, false); break;
|
|
990
|
+
default: sprite(T.dot, PM, q.x, q.y, r * 2.4, r * 2.4, 0, c, 0.3 + q.z * 0.55, false);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
flush();
|
|
995
|
+
|
|
996
|
+
// final pass to the screen, the whole canvas: the frame at its place in it (S.out)
|
|
997
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
998
|
+
gl.viewport(0, 0, CW, CH);
|
|
999
|
+
const lt = spec.light || {};
|
|
1000
|
+
gl.useProgram(pprog);
|
|
1001
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
1002
|
+
gl.bindTexture(gl.TEXTURE_2D, S.scene.tex);
|
|
1003
|
+
gl.uniform1i(pu.scene, 0);
|
|
1004
|
+
gl.uniform2f(pu.res, W, H);
|
|
1005
|
+
gl.uniform2f(pu.at, OX, CH - OY - H);
|
|
1006
|
+
gl.uniform2f(pu.canvas, CW, CH);
|
|
1007
|
+
gl.uniform2f(pu.off, OX, OY);
|
|
1008
|
+
gl.uniform3f(pu.tint, ...(lt.tint ? col(lt.tint) : WHITE));
|
|
1009
|
+
gl.uniform1f(pu.tintA, lt.tint ? I.clamp(lt.tintStrength ?? 0, 0, 0.5) : 0);
|
|
1010
|
+
gl.uniform1f(pu.vig, 0.75 * I.clamp(lt.vignette ?? 0.5, 0, 1));
|
|
1011
|
+
gl.uniform1f(pu.grain, 0.1 * I.clamp(lt.grain ?? 0.5, 0, 1));
|
|
1012
|
+
gl.uniform1f(pu.seed, Math.floor(Math.random() * 4096));
|
|
1013
|
+
// a film's cut: the frame fades to a colour (state: fade 0…1, fade_color — black by default)
|
|
1014
|
+
const sv = I.STATE.values;
|
|
1015
|
+
gl.uniform1f(pu.fade, typeof sv.fade === 'number' ? I.clamp(sv.fade, 0, 1) : 0);
|
|
1016
|
+
gl.uniform3f(pu.fadeCol, ...(typeof sv.fade_color === 'string' ? col(sv.fade_color) : [0, 0, 0]));
|
|
1017
|
+
band(pprog, VY0, VY1);
|
|
1018
|
+
|
|
1019
|
+
refine(S, cam, goal || cam);
|
|
1020
|
+
evict(S);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
return { name: 'webgl', fpsIdle: 60, pixelBudget: Infinity, resMax: 2, build, frame };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return { create };
|
|
1027
|
+
})();
|