@chromatic-coherence/generative-engine 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/world.js ADDED
@@ -0,0 +1,764 @@
1
+ // generative-engine — world.ts: THE THIRD ENGINE's core. The charts-3d API rides on top
2
+ // unchanged (face/line/glow/grow/draw*/fade/project/pick), and underneath it is a
3
+ // WebGL2 renderer: HDR scene target + post chain (bloom, SSAO, composite, FXAA),
4
+ // hardware-PCF depth-texture shadows, per-group transforms + morph targets in the
5
+ // vertex stage, mesh + ribbon primitives. Zero third-party imports, on purpose.
6
+ import { v3, add, sub, scale, cross, norm, clamp, hsl, mIdent, mPerspective, mLookAt, mMul, mCompose, normalMat3, } from "./math.js";
7
+ import { FACE_VS, FACE_FS, DEPTH_VS, DEPTH_FS, LINE_VS, LINE_FS, PT_VS, PT_FS, RIBBON_VS, RIBBON_FS, } from "./shaders.js";
8
+ import { PInfo, PostChain } from "./post.js";
9
+ const FACE_STRIDE = 18; // pos3 norm3 rgb3 gloss1 blood1 grp1 pos2:3 norm2:3
10
+ const LINE_STRIDE = 8; // pos3 col4 grp1
11
+ const PT_STRIDE = 9; // pos3 size1 col4 grp1
12
+ const RIB_STRIDE = 16; // pos3 prev3 next3 side1 width1 col4 grp1
13
+ export class World {
14
+ idxFor(name) {
15
+ const n = name ?? "default";
16
+ let i = this.groupIdx.get(n);
17
+ if (i == null) {
18
+ i = Math.min(7, this.groupIdx.size);
19
+ this.groupIdx.set(n, i);
20
+ }
21
+ return i;
22
+ }
23
+ /** Fade a named group toward a target alpha — eased per frame. */
24
+ fade(name, target) { this.groupTarget[this.idxFor(name)] = clamp(target, 0, 1); }
25
+ /** Blend a named group toward its morph target (meshMorph geometry) — eased per frame. */
26
+ morph(name, target) { this.groupMorphT[this.idxFor(name)] = clamp(target, 0, 1); }
27
+ /** Set a named group's model transform — a mat4 or a {translate, rotateY, scale...} spec.
28
+ * Geometry in the group moves rigidly with NO CPU re-tessellation. */
29
+ setTransform(name, m) {
30
+ const i = this.idxFor(name);
31
+ const mat = m instanceof Float32Array ? m : mCompose(m);
32
+ this.groupMat.set(mat, i * 16);
33
+ this.groupNM.set(normalMat3(mat), i * 9);
34
+ }
35
+ constructor(o) {
36
+ this.ink = false;
37
+ this.bg = [0.024, 0.016, 0.055];
38
+ this.transparent = false;
39
+ this.shadowSize = 2048;
40
+ this.shadowSoft = 1.6;
41
+ this.shadowBias = 0.0035;
42
+ this.shadowFBO = null;
43
+ this.shadowTex = null;
44
+ this.svp = mIdent();
45
+ // static stores (rebuilt on staticDirty) + per-frame dynamic stores
46
+ this.sFace = [];
47
+ this.sLine = [];
48
+ this.sPt = [];
49
+ this.sRib = [];
50
+ this.dFaceA = [];
51
+ this.dLineA = [];
52
+ this.dPtA = [];
53
+ this.dRibA = [];
54
+ this.labels = [];
55
+ this.staticDirty = true;
56
+ this.hooks = [];
57
+ this.raf = 0;
58
+ this.t0 = 0;
59
+ this.tPrev = 0;
60
+ this.disposed = false;
61
+ this.lastT = -1; // last rendered time — lets a paused world repaint after resize
62
+ this.cleanup = [];
63
+ this.fLight = [];
64
+ this.fRib = [];
65
+ this.cam = { target: v3(0, 0, 0), dist: 700, pitch: 0.5, yawBase: 0, yawUser: 0, pitchUser: 0, distT: 700, fov: 1.15 };
66
+ this.light = { dir: v3(0, 1, 0), ambient: 0.55, diffuse: 0.45, signed: false };
67
+ /** up to 8 point lights; lights[0] casts the shadow map (when shadows are on) */
68
+ this.lights = [];
69
+ /** per-pixel specular strength (0 = matte); skin catches the light around 0.5 */
70
+ this.specular = 0;
71
+ this.ambientSky = v3(1, 1, 1);
72
+ this.ambientGround = v3(1, 1, 1);
73
+ /** filmic tone map (ACES-ish) — runs in the composite pass now */
74
+ this.filmic = false;
75
+ /** the colour grade: violet-cool shadows, warm highlights — composite pass */
76
+ this.grade = false;
77
+ /** cinematic vignette 0..1 — composite pass (overlay fallback when post is off) */
78
+ this.vignette = 0;
79
+ /** the global heartbeat 0..1 — rides the per-face blood */
80
+ this.bloodPulse = 0;
81
+ /** HDR bloom — the light-on-dark aesthetic's native glow. Art-directable. */
82
+ this.bloom = { on: true, threshold: 0.82, knee: 0.35, strength: 0.65 };
83
+ /** depth-based SSAO — contact shading where surfaces meet */
84
+ this.ssao = { on: true, strength: 0.55, radius: 22 };
85
+ /** FXAA on the final composite */
86
+ this.fxaa = true;
87
+ // groups: fade alpha + morph weight + transform, resolved in the vertex stage
88
+ this.groupIdx = new Map([["default", 0]]);
89
+ this.groupAlpha = new Float32Array(8).fill(1);
90
+ this.groupTarget = new Float32Array(8).fill(1);
91
+ this.groupMorph = new Float32Array(8);
92
+ this.groupMorphT = new Float32Array(8);
93
+ this.groupMat = new Float32Array(8 * 16);
94
+ this.groupNM = new Float32Array(8 * 9);
95
+ this.W = 0;
96
+ this.H = 0;
97
+ this.mx = 0;
98
+ this.my = 0;
99
+ this.mxT = 0;
100
+ this.myT = 0;
101
+ this.yawUserT = 0;
102
+ this.pitchUserT = 0;
103
+ this.cx = v3();
104
+ this.rt = v3();
105
+ this.up = v3();
106
+ this.fw = v3();
107
+ this.focal = 0;
108
+ this.vp = mIdent();
109
+ this.zNear = 24;
110
+ this.zFar = 6000;
111
+ this.inkCss = new Map();
112
+ this.canvas = o.canvas;
113
+ const gl = o.canvas.getContext("webgl2", { alpha: true, antialias: false, premultipliedAlpha: true });
114
+ if (!gl)
115
+ throw new Error("generative-engine: no webgl2 context"); // callers fall back — never take the page down
116
+ this.gl = gl;
117
+ this.ink = o.theme === "light";
118
+ this.transparent = o.background === "transparent";
119
+ if (this.transparent) {
120
+ this.bg = [0, 0, 0];
121
+ }
122
+ else if (o.background && o.background !== "transparent") {
123
+ this.bg = hsl(o.background.hue, o.background.sat ?? 45, o.background.light ?? 5);
124
+ }
125
+ else {
126
+ this.bg = this.ink ? [0.90, 0.92, 0.95] : hsl(258, 48, 4);
127
+ }
128
+ this.opts = {
129
+ maxDpr: o.maxDpr ?? 2,
130
+ drift: o.drift ?? 0.045,
131
+ parallax: o.parallax ?? 0.3,
132
+ controls: o.controls ?? false,
133
+ };
134
+ if (o.camera) {
135
+ if (o.camera.target)
136
+ this.cam.target = o.camera.target;
137
+ if (o.camera.dist != null) {
138
+ this.cam.dist = o.camera.dist;
139
+ this.cam.distT = o.camera.dist;
140
+ }
141
+ if (o.camera.pitch != null)
142
+ this.cam.pitch = o.camera.pitch;
143
+ if (o.camera.yaw != null)
144
+ this.cam.yawBase = o.camera.yaw;
145
+ if (o.camera.fov != null)
146
+ this.cam.fov = o.camera.fov;
147
+ }
148
+ for (let i = 0; i < 8; i++) {
149
+ this.groupMat.set(mIdent(), i * 16);
150
+ this.groupNM.set([1, 0, 0, 0, 1, 0, 0, 0, 1], i * 9);
151
+ }
152
+ this.pFace = new PInfo(gl, FACE_VS, FACE_FS);
153
+ this.pLine = new PInfo(gl, LINE_VS, LINE_FS);
154
+ this.pPt = new PInfo(gl, PT_VS, PT_FS);
155
+ this.pRib = new PInfo(gl, RIBBON_VS, RIBBON_FS);
156
+ this.pDepth = new PInfo(gl, DEPTH_VS, DEPTH_FS);
157
+ this.post = (o.post ?? true) && !this.transparent ? new PostChain(gl) : null;
158
+ this.shadowsOn = !!o.shadows;
159
+ if (o.shadows && typeof o.shadows === "object") {
160
+ this.shadowSize = o.shadows.size ?? 2048;
161
+ this.shadowSoft = o.shadows.softness ?? 1.6;
162
+ this.shadowBias = o.shadows.bias ?? 0.0035;
163
+ }
164
+ if (this.shadowsOn) {
165
+ // a REAL depth texture with hardware comparison — LINEAR gives free 2x2 PCF per tap
166
+ this.shadowTex = gl.createTexture();
167
+ gl.bindTexture(gl.TEXTURE_2D, this.shadowTex);
168
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, this.shadowSize, this.shadowSize, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null);
169
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
170
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
171
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
172
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
173
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE);
174
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_FUNC, gl.LEQUAL);
175
+ this.shadowFBO = gl.createFramebuffer();
176
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFBO);
177
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, this.shadowTex, 0);
178
+ gl.drawBuffers([gl.NONE]);
179
+ gl.readBuffer(gl.NONE);
180
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
181
+ }
182
+ this.bFace = gl.createBuffer();
183
+ this.bLine = gl.createBuffer();
184
+ this.bPt = gl.createBuffer();
185
+ this.bRib = gl.createBuffer();
186
+ this.bFaceD = gl.createBuffer();
187
+ this.bLineD = gl.createBuffer();
188
+ this.bPtD = gl.createBuffer();
189
+ this.bRibD = gl.createBuffer();
190
+ // the label overlay — a synced 2D canvas floated over the GL surface
191
+ this.overlay = document.createElement("canvas");
192
+ this.overlay.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;";
193
+ const parent = this.canvas.parentElement;
194
+ if (parent) {
195
+ if (getComputedStyle(parent).position === "static")
196
+ parent.style.position = "relative";
197
+ parent.appendChild(this.overlay);
198
+ }
199
+ this.og = this.overlay.getContext("2d");
200
+ this.resize();
201
+ const ro = new ResizeObserver(() => this.resize());
202
+ ro.observe(this.canvas);
203
+ this.cleanup.push(() => ro.disconnect());
204
+ const onMouse = (e) => {
205
+ this.mxT = (e.clientX / window.innerWidth - 0.5) * 2;
206
+ this.myT = (e.clientY / window.innerHeight - 0.5) * 2;
207
+ };
208
+ window.addEventListener("mousemove", onMouse);
209
+ this.cleanup.push(() => window.removeEventListener("mousemove", onMouse));
210
+ if (this.opts.controls)
211
+ this.wireControls();
212
+ }
213
+ wireControls() {
214
+ const cv = this.canvas;
215
+ cv.style.touchAction = "pan-y";
216
+ cv.style.cursor = "grab";
217
+ let dragging = false, lx = 0, ly = 0;
218
+ const down = (e) => { dragging = true; lx = e.clientX; ly = e.clientY; cv.style.cursor = "grabbing"; cv.setPointerCapture(e.pointerId); };
219
+ const move = (e) => {
220
+ if (!dragging)
221
+ return;
222
+ this.yawUserT -= (e.clientX - lx) * 0.0045;
223
+ this.pitchUserT = clamp(this.pitchUserT + (e.clientY - ly) * 0.003, -0.3, 0.4);
224
+ lx = e.clientX;
225
+ ly = e.clientY;
226
+ };
227
+ const upFn = () => { dragging = false; cv.style.cursor = "grab"; };
228
+ const wheel = (e) => {
229
+ if (!e.ctrlKey && !e.metaKey)
230
+ return;
231
+ e.preventDefault();
232
+ this.cam.distT = clamp(this.cam.distT + e.deltaY * 0.6, 120, 2200);
233
+ };
234
+ cv.addEventListener("pointerdown", down);
235
+ cv.addEventListener("pointermove", move);
236
+ cv.addEventListener("pointerup", upFn);
237
+ cv.addEventListener("pointercancel", upFn);
238
+ cv.addEventListener("wheel", wheel, { passive: false });
239
+ this.cleanup.push(() => {
240
+ cv.removeEventListener("pointerdown", down);
241
+ cv.removeEventListener("pointermove", move);
242
+ cv.removeEventListener("pointerup", upFn);
243
+ cv.removeEventListener("pointercancel", upFn);
244
+ cv.removeEventListener("wheel", wheel);
245
+ });
246
+ }
247
+ resize() {
248
+ const dpr = Math.min(window.devicePixelRatio || 1, this.opts.maxDpr);
249
+ const W = this.canvas.clientWidth, H = this.canvas.clientHeight;
250
+ const pw = Math.max(1, Math.round(W * dpr)), ph = Math.max(1, Math.round(H * dpr));
251
+ // no-op when nothing changed: ANY canvas.width assignment clears the drawing
252
+ // buffer, so the ResizeObserver's initial fire would wipe a settle()d still
253
+ if (W === this.W && H === this.H && this.canvas.width === pw && this.canvas.height === ph)
254
+ return;
255
+ this.W = W;
256
+ this.H = H;
257
+ this.canvas.width = pw;
258
+ this.canvas.height = ph;
259
+ this.overlay.width = this.canvas.width;
260
+ this.overlay.height = this.canvas.height;
261
+ this.og.setTransform(dpr, 0, 0, dpr, 0, 0);
262
+ this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
263
+ this.post?.resize(this.canvas.width, this.canvas.height);
264
+ // a resize clears the drawing buffer — a PAUSED world (settle() stills) repaints
265
+ // its last frame here so a late ResizeObserver fire never leaves a blank canvas
266
+ if (!this.raf && this.lastT >= 0 && !this.disposed)
267
+ this.renderFrame(this.lastT, 0);
268
+ }
269
+ // ── theme: the ink edition inverts lightness; recipes pass through unchanged when dark ──
270
+ col(h, s, l) {
271
+ return hsl(h, s, this.ink ? 100 - l : l);
272
+ }
273
+ parseCol(c) {
274
+ const m = /hsl\(\s*([\d.]+)[deg\s]*[, ]\s*([\d.]+)%[, ]\s*([\d.]+)%/.exec(c);
275
+ return m ? this.col(+m[1], +m[2], +m[3]) : (this.ink ? [0.18, 0.18, 0.28] : [0.9, 0.9, 1]);
276
+ }
277
+ themedCss(color) {
278
+ if (!this.ink)
279
+ return color;
280
+ let c = this.inkCss.get(color);
281
+ if (!c) {
282
+ const m = /hsl\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%\s*\)/.exec(color);
283
+ c = m ? `hsl(${m[1]} ${m[2]}% ${clamp(100 - +m[3], 0, 100)}%)` : color;
284
+ this.inkCss.set(color, c);
285
+ }
286
+ return c;
287
+ }
288
+ // ── building the static world ──
289
+ pushFace(store, a, b, c, o, na, nb, nc, a2, b2, c2, na2, nb2, nc2) {
290
+ const n = na ? undefined : norm(cross(sub(b, a), sub(c, a)));
291
+ const [r, g, bl] = this.col(o.hue, o.sat ?? 45, o.light ?? 30);
292
+ const gi = this.idxFor(o.group);
293
+ const gloss = clamp(o.gloss ?? 0.3, 0, 1);
294
+ const blood = clamp(o.blood ?? 0, 0, 1);
295
+ const verts = [
296
+ [a, na ?? n, a2 ?? a, na2 ?? na ?? n],
297
+ [b, nb ?? n, b2 ?? b, nb2 ?? nb ?? n],
298
+ [c, nc ?? n, c2 ?? c, nc2 ?? nc ?? n],
299
+ ];
300
+ for (const [p, nn, p2, nn2] of verts)
301
+ store.push(p.x, p.y, p.z, nn.x, nn.y, nn.z, r, g, bl, gloss, blood, gi, p2.x, p2.y, p2.z, nn2.x, nn2.y, nn2.z);
302
+ }
303
+ /** One static triangle. Pass per-vertex normals for smooth shading; omit for flat. */
304
+ face(a, b, c, o, na, nb, nc) {
305
+ this.pushFace(this.sFace, a, b, c, o, na, nb, nc);
306
+ this.staticDirty = true;
307
+ }
308
+ /** Upload a whole Mesh (geometry.ts builders) as static faces in one call. */
309
+ mesh(geo, o) {
310
+ const P = geo.pos, N = geo.nrm;
311
+ for (let i = 0; i < P.length; i += 9) {
312
+ this.pushFace(this.sFace, v3(P[i], P[i + 1], P[i + 2]), v3(P[i + 3], P[i + 4], P[i + 5]), v3(P[i + 6], P[i + 7], P[i + 8]), o, v3(N[i], N[i + 1], N[i + 2]), v3(N[i + 3], N[i + 4], N[i + 5]), v3(N[i + 6], N[i + 7], N[i + 8]));
313
+ }
314
+ this.staticDirty = true;
315
+ }
316
+ /** Upload a morph pair: the group's morph weight blends A -> B in the vertex shader.
317
+ * The two meshes MUST have identical vertex counts (build them from the same recipe). */
318
+ meshMorph(geoA, geoB, o) {
319
+ if (geoA.pos.length !== geoB.pos.length)
320
+ throw new Error(`generative-engine meshMorph: vertex counts differ (${geoA.pos.length / 3} vs ${geoB.pos.length / 3})`);
321
+ const P = geoA.pos, N = geoA.nrm, Q = geoB.pos, M = geoB.nrm;
322
+ for (let i = 0; i < P.length; i += 9) {
323
+ this.pushFace(this.sFace, v3(P[i], P[i + 1], P[i + 2]), v3(P[i + 3], P[i + 4], P[i + 5]), v3(P[i + 6], P[i + 7], P[i + 8]), o, v3(N[i], N[i + 1], N[i + 2]), v3(N[i + 3], N[i + 4], N[i + 5]), v3(N[i + 6], N[i + 7], N[i + 8]), v3(Q[i], Q[i + 1], Q[i + 2]), v3(Q[i + 3], Q[i + 4], Q[i + 5]), v3(Q[i + 6], Q[i + 7], Q[i + 8]), v3(M[i], M[i + 1], M[i + 2]), v3(M[i + 3], M[i + 4], M[i + 5]), v3(M[i + 6], M[i + 7], M[i + 8]));
324
+ }
325
+ this.staticDirty = true;
326
+ }
327
+ line(a, b, o) {
328
+ const [r, g, bl] = this.col(o.hue, o.sat ?? 80, o.light ?? 55);
329
+ const al = clamp(o.alpha, 0, 1), gi = this.idxFor(o.group);
330
+ this.sLine.push(a.x, a.y, a.z, r, g, bl, al, gi, b.x, b.y, b.z, r, g, bl, al, gi);
331
+ this.staticDirty = true;
332
+ }
333
+ glow(p, o) {
334
+ const [r, g, bl] = o.white ? (this.ink ? [0.16, 0.15, 0.24] : [1, 1, 1]) : this.col(o.hue ?? 0, 85, 62);
335
+ this.sPt.push(p.x, p.y, p.z, o.size, r, g, bl, clamp(o.alpha, 0, 1), this.idxFor(o.group));
336
+ this.staticDirty = true;
337
+ }
338
+ dust(p, alpha) {
339
+ if (this.ink)
340
+ this.sPt.push(p.x, p.y, p.z, 1.6, 0.29, 0.31, 0.45, clamp(alpha * 0.6, 0, 1), 0);
341
+ else
342
+ this.sPt.push(p.x, p.y, p.z, 1.6, 0.87, 0.89, 1, clamp(alpha * 0.6, 0, 1), 0);
343
+ this.staticDirty = true;
344
+ }
345
+ pushRibbon(store, pts, o) {
346
+ const n = pts.length;
347
+ if (n < 2)
348
+ return;
349
+ const [r, g, bl] = this.col(o.hue, o.sat ?? 80, o.light ?? 55);
350
+ const al = clamp(o.alpha, 0, 1), gi = this.idxFor(o.group), w = o.width;
351
+ const vert = (i, side) => {
352
+ const p = pts[i], q = pts[Math.max(0, i - 1)], s = pts[Math.min(n - 1, i + 1)];
353
+ store.push(p.x, p.y, p.z, q.x, q.y, q.z, s.x, s.y, s.z, side, w, r, g, bl, al, gi);
354
+ };
355
+ for (let i = 0; i < n - 1; i++) {
356
+ vert(i, -1);
357
+ vert(i, 1);
358
+ vert(i + 1, -1);
359
+ vert(i, 1);
360
+ vert(i + 1, 1);
361
+ vert(i + 1, -1);
362
+ }
363
+ }
364
+ /** A static ribbon stroke — real pixel width, feathered edges, depth-tested. */
365
+ ribbon(pts, o) { this.pushRibbon(this.sRib, pts, o); this.staticDirty = true; }
366
+ grow(fn) { this.hooks.push(fn); }
367
+ // ── the living verbs (gathered per frame) ──
368
+ drawLine(a, b, color, alpha, width = 1) {
369
+ this.fLight.push({ k: "line", a, b, color, alpha, width });
370
+ }
371
+ drawPath(pts, color, alpha, width = 1) {
372
+ this.fLight.push({ k: "poly", pts, color, alpha, width });
373
+ }
374
+ drawGlow(p, hue, size, alpha) {
375
+ this.fLight.push({ k: "glow", p, hue, size, alpha });
376
+ }
377
+ drawLabel(p, text, color, alpha, dy = 0) {
378
+ this.fLight.push({ k: "label", p, text, color, alpha, dy });
379
+ }
380
+ drawFace(a, b, c, o, na, nb, nc) {
381
+ this.pushFace(this.dFaceA, a, b, c, o, na, nb, nc);
382
+ }
383
+ /** A per-frame ribbon stroke — the animated counterpart of ribbon(). */
384
+ drawRibbon(pts, o) { this.fRib.push({ pts, o }); }
385
+ /** Screen-space overlay (the 2D label canvas — never occluded). */
386
+ overlay2d(cb) { cb(this.og, this); }
387
+ // ── camera + CPU projection for pick/labels ──
388
+ setupCamera(t, dt) {
389
+ this.mx += (this.mxT - this.mx) * Math.min(1, dt * 3);
390
+ this.my += (this.myT - this.my) * Math.min(1, dt * 3);
391
+ this.cam.yawUser += (this.yawUserT - this.cam.yawUser) * Math.min(1, dt * 5);
392
+ this.cam.pitchUser += (this.pitchUserT - this.cam.pitchUser) * Math.min(1, dt * 5);
393
+ this.cam.dist += (this.cam.distT - this.cam.dist) * Math.min(1, dt * 4);
394
+ const yaw = this.cam.yawBase + t * this.opts.drift + this.cam.yawUser + this.mx * this.opts.parallax;
395
+ const pitch = clamp(this.cam.pitch + this.cam.pitchUser + this.my * this.opts.parallax * 0.5, 0.18, 0.95);
396
+ const cp = Math.cos(pitch), sp = Math.sin(pitch);
397
+ this.cx = add(this.cam.target, scale(v3(cp * Math.cos(yaw), sp, cp * Math.sin(yaw)), this.cam.dist));
398
+ this.fw = norm(sub(this.cam.target, this.cx));
399
+ this.rt = norm(cross(this.fw, v3(0, 1, 0)));
400
+ this.up = cross(this.rt, this.fw);
401
+ this.focal = Math.min(this.W, this.H) * this.cam.fov;
402
+ const fovy = 2 * Math.atan(this.H / (2 * this.focal));
403
+ this.vp = mMul(mPerspective(fovy, this.W / this.H, this.zNear, this.zFar), mLookAt(this.cx, this.cam.target, v3(0, 1, 0)));
404
+ }
405
+ project(p) {
406
+ const dx = p.x - this.cx.x, dy = p.y - this.cx.y, dz = p.z - this.cx.z;
407
+ const z = dx * this.fw.x + dy * this.fw.y + dz * this.fw.z;
408
+ if (z < this.zNear)
409
+ return null;
410
+ const f = this.focal / z;
411
+ return {
412
+ x: this.W / 2 + (dx * this.rt.x + dy * this.rt.y + dz * this.rt.z) * f,
413
+ y: this.H / 2 - (dx * this.up.x + dy * this.up.y + dz * this.up.z) * f,
414
+ s: f, z,
415
+ };
416
+ }
417
+ fog(z) { return Math.min(1, Math.max(0.08, (this.cam.dist * 0.9) / z)); }
418
+ pick(sx, sy, planeY = 0) {
419
+ const rx = (sx - this.W / 2) / this.focal, ry = -(sy - this.H / 2) / this.focal;
420
+ const dir = norm(add(add(this.fw, scale(this.rt, rx)), scale(this.up, ry)));
421
+ if (Math.abs(dir.y) < 1e-5)
422
+ return null;
423
+ const s = (planeY - this.cx.y) / dir.y;
424
+ if (s < this.zNear)
425
+ return null;
426
+ return add(this.cx, scale(dir, s));
427
+ }
428
+ get surface() { return this.canvas; }
429
+ // ── uniform helpers ──
430
+ setGroups(p) {
431
+ const gl = this.gl;
432
+ gl.uniformMatrix4fv(p.u("uGM[0]"), false, this.groupMat);
433
+ gl.uniformMatrix3fv(p.u("uNM[0]"), false, this.groupNM);
434
+ gl.uniform1fv(p.u("uMW[0]"), this.groupMorph);
435
+ gl.uniform1fv(p.u("uGA[0]"), this.groupAlpha);
436
+ }
437
+ setVP(p) {
438
+ this.gl.uniformMatrix4fv(p.u("uVP"), false, this.vp);
439
+ this.setGroups(p);
440
+ }
441
+ setFog(p, fogK) {
442
+ this.gl.uniform1f(p.u("uFogK"), fogK);
443
+ this.gl.uniform1f(p.u("uInk"), this.ink ? 1 : 0);
444
+ }
445
+ setLights(p, fogK) {
446
+ const gl = this.gl, L = norm(this.light.dir);
447
+ gl.uniform3f(p.u("uLdir"), L.x, L.y, L.z);
448
+ gl.uniform1f(p.u("uAmb"), this.ink ? Math.min(1, this.light.ambient + 0.22) : this.light.ambient);
449
+ gl.uniform1f(p.u("uDif"), this.ink ? this.light.diffuse * 0.55 : this.light.diffuse);
450
+ gl.uniform1f(p.u("uSigned"), this.light.signed ? 1 : 0);
451
+ gl.uniform3f(p.u("uCam"), this.cx.x, this.cx.y, this.cx.z);
452
+ gl.uniform1f(p.u("uSpec"), this.specular);
453
+ gl.uniform1f(p.u("uPulse"), this.bloodPulse);
454
+ gl.uniform3f(p.u("uBg"), this.bg[0], this.bg[1], this.bg[2]);
455
+ gl.uniform3f(p.u("uSkyC"), this.ambientSky.x, this.ambientSky.y, this.ambientSky.z);
456
+ gl.uniform3f(p.u("uGroundC"), this.ambientGround.x, this.ambientGround.y, this.ambientGround.z);
457
+ gl.uniformMatrix4fv(p.u("uSVP"), false, this.svp);
458
+ const shOn = this.shadowsOn && this.shadowTex && this.lights.length > 0;
459
+ gl.uniform1f(p.u("uShadowOn"), shOn ? 1 : 0);
460
+ gl.uniform1f(p.u("uShadowSoft"), this.shadowSoft);
461
+ gl.uniform1f(p.u("uShadowBias"), this.shadowBias);
462
+ gl.uniform1f(p.u("uShadowPx"), 1 / this.shadowSize);
463
+ if (this.shadowTex) {
464
+ gl.activeTexture(gl.TEXTURE0);
465
+ gl.bindTexture(gl.TEXTURE_2D, this.shadowTex);
466
+ gl.uniform1i(p.u("uShadow"), 0);
467
+ }
468
+ gl.uniform1f(p.u("uFogK"), fogK);
469
+ const n = Math.min(8, this.lights.length);
470
+ gl.uniform1i(p.u("uLn"), n);
471
+ const lp = new Float32Array(24), li = new Float32Array(8), lf = new Float32Array(8);
472
+ for (let i = 0; i < n; i++) {
473
+ lp[i * 3] = this.lights[i].p.x;
474
+ lp[i * 3 + 1] = this.lights[i].p.y;
475
+ lp[i * 3 + 2] = this.lights[i].p.z;
476
+ li[i] = this.lights[i].intensity;
477
+ lf[i] = this.lights[i].falloff;
478
+ }
479
+ gl.uniform3fv(p.u("uLp[0]"), lp);
480
+ gl.uniform1fv(p.u("uLi[0]"), li);
481
+ gl.uniform1fv(p.u("uLf[0]"), lf);
482
+ }
483
+ attrib(p, name, size, stride, off) {
484
+ const gl = this.gl, loc = gl.getAttribLocation(p.prog, name);
485
+ if (loc < 0)
486
+ return;
487
+ gl.enableVertexAttribArray(loc);
488
+ gl.vertexAttribPointer(loc, size, gl.FLOAT, false, stride * 4, off * 4);
489
+ }
490
+ // ── draw helpers ──
491
+ depthDraw(buf, verts) {
492
+ if (!verts)
493
+ return;
494
+ const gl = this.gl;
495
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
496
+ this.attrib(this.pDepth, "aPos", 3, FACE_STRIDE, 0);
497
+ this.attrib(this.pDepth, "aGrp", 1, FACE_STRIDE, 11);
498
+ this.attrib(this.pDepth, "aPos2", 3, FACE_STRIDE, 12);
499
+ gl.drawArrays(gl.TRIANGLES, 0, verts);
500
+ }
501
+ drawFaces(buf, verts, dyn) {
502
+ if (!verts)
503
+ return;
504
+ const gl = this.gl;
505
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
506
+ if (dyn)
507
+ gl.bufferData(gl.ARRAY_BUFFER, dyn, gl.DYNAMIC_DRAW);
508
+ this.attrib(this.pFace, "aPos", 3, FACE_STRIDE, 0);
509
+ this.attrib(this.pFace, "aNorm", 3, FACE_STRIDE, 3);
510
+ this.attrib(this.pFace, "aCol", 4, FACE_STRIDE, 6);
511
+ this.attrib(this.pFace, "aBlood", 1, FACE_STRIDE, 10);
512
+ this.attrib(this.pFace, "aGrp", 1, FACE_STRIDE, 11);
513
+ this.attrib(this.pFace, "aPos2", 3, FACE_STRIDE, 12);
514
+ this.attrib(this.pFace, "aNorm2", 3, FACE_STRIDE, 15);
515
+ gl.drawArrays(gl.TRIANGLES, 0, verts);
516
+ }
517
+ drawLines(buf, verts, dyn) {
518
+ if (!verts)
519
+ return;
520
+ const gl = this.gl;
521
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
522
+ if (dyn)
523
+ gl.bufferData(gl.ARRAY_BUFFER, dyn, gl.DYNAMIC_DRAW);
524
+ this.attrib(this.pLine, "aPos", 3, LINE_STRIDE, 0);
525
+ this.attrib(this.pLine, "aCol", 4, LINE_STRIDE, 3);
526
+ this.attrib(this.pLine, "aGrp", 1, LINE_STRIDE, 7);
527
+ gl.drawArrays(gl.LINES, 0, verts);
528
+ }
529
+ drawPts(buf, verts, dyn) {
530
+ if (!verts)
531
+ return;
532
+ const gl = this.gl;
533
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
534
+ if (dyn)
535
+ gl.bufferData(gl.ARRAY_BUFFER, dyn, gl.DYNAMIC_DRAW);
536
+ this.attrib(this.pPt, "aPos", 3, PT_STRIDE, 0);
537
+ this.attrib(this.pPt, "aSize", 1, PT_STRIDE, 3);
538
+ this.attrib(this.pPt, "aCol", 4, PT_STRIDE, 4);
539
+ this.attrib(this.pPt, "aGrp", 1, PT_STRIDE, 8);
540
+ gl.drawArrays(gl.POINTS, 0, verts);
541
+ }
542
+ drawRibbons(buf, verts, dyn) {
543
+ if (!verts)
544
+ return;
545
+ const gl = this.gl;
546
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
547
+ if (dyn)
548
+ gl.bufferData(gl.ARRAY_BUFFER, dyn, gl.DYNAMIC_DRAW);
549
+ this.attrib(this.pRib, "aPos", 3, RIB_STRIDE, 0);
550
+ this.attrib(this.pRib, "aPrev", 3, RIB_STRIDE, 3);
551
+ this.attrib(this.pRib, "aNext", 3, RIB_STRIDE, 6);
552
+ this.attrib(this.pRib, "aSide", 1, RIB_STRIDE, 9);
553
+ this.attrib(this.pRib, "aWidth", 1, RIB_STRIDE, 10);
554
+ this.attrib(this.pRib, "aCol", 4, RIB_STRIDE, 11);
555
+ this.attrib(this.pRib, "aGrp", 1, RIB_STRIDE, 15);
556
+ gl.drawArrays(gl.TRIANGLES, 0, verts);
557
+ }
558
+ // ── the frame ──
559
+ renderFrame(t, dt) {
560
+ const gl = this.gl;
561
+ this.lastT = t;
562
+ this.setupCamera(t, dt);
563
+ this.fLight.length = 0;
564
+ this.fRib.length = 0;
565
+ this.dFaceA.length = 0;
566
+ this.dLineA.length = 0;
567
+ this.dPtA.length = 0;
568
+ this.dRibA.length = 0;
569
+ for (const fn of this.hooks)
570
+ fn(t, dt, this);
571
+ this.labels.length = 0;
572
+ for (const rec of this.fLight) {
573
+ if (rec.k === "label") {
574
+ this.labels.push(rec);
575
+ continue;
576
+ }
577
+ if (rec.k === "glow") {
578
+ const [r, g, b] = rec.hue == null ? (this.ink ? [0.16, 0.15, 0.24] : [1, 1, 1]) : this.col(rec.hue, 85, 62);
579
+ this.dPtA.push(rec.p.x, rec.p.y, rec.p.z, rec.size, r, g, b, clamp(rec.alpha, 0, 1), 0);
580
+ }
581
+ else if (rec.k === "line") {
582
+ const [r, g, b] = this.parseCol(rec.color);
583
+ this.dLineA.push(rec.a.x, rec.a.y, rec.a.z, r, g, b, rec.alpha, 0, rec.b.x, rec.b.y, rec.b.z, r, g, b, rec.alpha, 0);
584
+ }
585
+ else if (rec.k === "poly") {
586
+ const [r, g, b] = this.parseCol(rec.color);
587
+ for (let i = 1; i < rec.pts.length; i++) {
588
+ const a = rec.pts[i - 1].p, q = rec.pts[i].p;
589
+ this.dLineA.push(a.x, a.y, a.z, r, g, b, rec.alpha, 0, q.x, q.y, q.z, r, g, b, rec.alpha, 0);
590
+ }
591
+ }
592
+ }
593
+ for (const rb of this.fRib)
594
+ this.pushRibbon(this.dRibA, rb.pts, rb.o);
595
+ const fogK = this.cam.dist * 0.9;
596
+ for (let i = 0; i < 8; i++) { // eased group alphas + morph weights
597
+ this.groupAlpha[i] += (this.groupTarget[i] - this.groupAlpha[i]) * Math.min(1, dt * 6);
598
+ this.groupMorph[i] += (this.groupMorphT[i] - this.groupMorph[i]) * Math.min(1, dt * 6);
599
+ }
600
+ if (this.staticDirty) {
601
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.bFace);
602
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(this.sFace), gl.STATIC_DRAW);
603
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.bLine);
604
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(this.sLine), gl.STATIC_DRAW);
605
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.bPt);
606
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(this.sPt), gl.STATIC_DRAW);
607
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.bRib);
608
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(this.sRib), gl.STATIC_DRAW);
609
+ this.staticDirty = false;
610
+ }
611
+ if (this.dFaceA.length) {
612
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.bFaceD);
613
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(this.dFaceA), gl.DYNAMIC_DRAW);
614
+ }
615
+ // the shadow pass: the world as lights[0] sees it, into a REAL depth texture
616
+ if (this.shadowsOn && this.shadowFBO && this.lights.length) {
617
+ const lp = this.lights[0].p;
618
+ const dir = norm(sub(this.cam.target, lp));
619
+ const up = Math.abs(dir.y) > 0.95 ? v3(1, 0, 0) : v3(0, 1, 0);
620
+ this.svp = mMul(mPerspective(1.15, 1, 40, 3500), mLookAt(lp, this.cam.target, up));
621
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFBO);
622
+ gl.viewport(0, 0, this.shadowSize, this.shadowSize);
623
+ gl.enable(gl.DEPTH_TEST);
624
+ gl.depthMask(true);
625
+ gl.disable(gl.BLEND);
626
+ gl.clear(gl.DEPTH_BUFFER_BIT);
627
+ this.pDepth.use();
628
+ gl.uniformMatrix4fv(this.pDepth.u("uVP"), false, this.svp);
629
+ this.setGroups(this.pDepth);
630
+ this.depthDraw(this.bFace, this.sFace.length / FACE_STRIDE);
631
+ if (this.dFaceA.length) {
632
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.bFaceD);
633
+ this.depthDraw(this.bFaceD, this.dFaceA.length / FACE_STRIDE);
634
+ }
635
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
636
+ }
637
+ // the scene pass — into the HDR target when the post chain is on
638
+ if (this.post)
639
+ this.post.begin();
640
+ else {
641
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
642
+ gl.viewport(0, 0, this.canvas.width, this.canvas.height);
643
+ }
644
+ if (this.transparent)
645
+ gl.clearColor(0, 0, 0, 0);
646
+ else
647
+ gl.clearColor(this.bg[0], this.bg[1], this.bg[2], 1);
648
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
649
+ // solids — depth-written, per-pixel lit, shadowed
650
+ gl.enable(gl.DEPTH_TEST);
651
+ gl.depthMask(true);
652
+ gl.disable(gl.BLEND);
653
+ this.pFace.use();
654
+ this.setVP(this.pFace);
655
+ this.setLights(this.pFace, fogK);
656
+ this.drawFaces(this.bFace, this.sFace.length / FACE_STRIDE, null);
657
+ if (this.dFaceA.length)
658
+ this.drawFaces(this.bFaceD, this.dFaceA.length / FACE_STRIDE, new Float32Array(this.dFaceA));
659
+ // the light — additive, depth-TESTED but not written (ink: normal alpha blend)
660
+ gl.depthMask(false);
661
+ gl.enable(gl.BLEND);
662
+ if (this.ink)
663
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
664
+ else
665
+ gl.blendFunc(gl.ONE, gl.ONE);
666
+ this.pLine.use();
667
+ this.setVP(this.pLine);
668
+ this.setFog(this.pLine, fogK);
669
+ this.drawLines(this.bLine, this.sLine.length / LINE_STRIDE, null);
670
+ if (this.dLineA.length)
671
+ this.drawLines(this.bLineD, this.dLineA.length / LINE_STRIDE, new Float32Array(this.dLineA));
672
+ this.pRib.use();
673
+ this.setVP(this.pRib);
674
+ this.setFog(this.pRib, fogK);
675
+ this.gl.uniform2f(this.pRib.u("uRes"), this.W, this.H);
676
+ this.drawRibbons(this.bRib, this.sRib.length / RIB_STRIDE, null);
677
+ if (this.dRibA.length)
678
+ this.drawRibbons(this.bRibD, this.dRibA.length / RIB_STRIDE, new Float32Array(this.dRibA));
679
+ this.pPt.use();
680
+ this.setVP(this.pPt);
681
+ this.setFog(this.pPt, fogK);
682
+ gl.uniform1f(this.pPt.u("uFocal"), this.focal * (this.canvas.width / Math.max(1, this.W)));
683
+ this.drawPts(this.bPt, this.sPt.length / PT_STRIDE, null);
684
+ if (this.dPtA.length)
685
+ this.drawPts(this.bPtD, this.dPtA.length / PT_STRIDE, new Float32Array(this.dPtA));
686
+ gl.depthMask(true);
687
+ // the post chain: bloom + SSAO + composite (+ FXAA) to the canvas
688
+ if (this.post) {
689
+ gl.disable(gl.BLEND);
690
+ this.post.end({
691
+ bloomOn: this.bloom.on && !this.ink, // bloom is light-on-dark; paper does not glow
692
+ bloomThreshold: this.bloom.threshold, bloomKnee: this.bloom.knee, bloomStrength: this.bloom.strength,
693
+ ssaoOn: this.ssao.on, ssaoStrength: this.ssao.strength, ssaoRadius: this.ssao.radius,
694
+ filmic: this.filmic && !this.ink, grade: this.grade, vignette: this.vignette, ink: this.ink,
695
+ fxaa: this.fxaa, near: this.zNear, far: this.zFar,
696
+ });
697
+ }
698
+ // labels — 2D overlay, never occluded (the honest exception)
699
+ this.og.clearRect(0, 0, this.W, this.H);
700
+ this.og.font = "600 11px ui-monospace, Menlo, monospace";
701
+ this.og.textAlign = "center";
702
+ this.og.textBaseline = "middle";
703
+ for (const l of this.labels) {
704
+ const q = this.project(l.p);
705
+ if (!q)
706
+ continue;
707
+ this.og.globalAlpha = clamp(l.alpha, 0, 1);
708
+ this.og.fillStyle = this.themedCss(l.color);
709
+ this.og.fillText(l.text, q.x, q.y + l.dy);
710
+ }
711
+ this.og.globalAlpha = 1;
712
+ if (this.vignette > 0 && !this.post) { // overlay fallback when the chain is off
713
+ const g = this.og, W = this.W, H = this.H;
714
+ const grd = g.createRadialGradient(W / 2, H * 0.46, Math.min(W, H) * 0.28, W / 2, H / 2, Math.max(W, H) * 0.72);
715
+ grd.addColorStop(0, "rgba(0,0,0,0)");
716
+ grd.addColorStop(1, this.ink ? `rgba(52,48,76,${this.vignette * 0.3})` : `rgba(6,4,14,${this.vignette})`);
717
+ g.fillStyle = grd;
718
+ g.fillRect(0, 0, W, H);
719
+ }
720
+ }
721
+ start() {
722
+ const frame = (ts) => {
723
+ if (this.disposed)
724
+ return;
725
+ if (!this.t0) {
726
+ this.t0 = ts;
727
+ this.tPrev = ts;
728
+ }
729
+ const t = (ts - this.t0) / 1000;
730
+ const dt = Math.min(0.05, (ts - this.tPrev) / 1000);
731
+ this.tPrev = ts;
732
+ this.renderFrame(t, dt);
733
+ this.raf = !document.hidden ? requestAnimationFrame(frame) : 0;
734
+ };
735
+ this.raf = requestAnimationFrame(frame);
736
+ const onVis = () => {
737
+ if (!document.hidden && !this.raf && !this.disposed) {
738
+ this.tPrev = performance.now();
739
+ this.raf = requestAnimationFrame(frame);
740
+ }
741
+ };
742
+ document.addEventListener("visibilitychange", onVis);
743
+ this.cleanup.push(() => document.removeEventListener("visibilitychange", onVis));
744
+ }
745
+ /** Render one deterministic frame at time t (no loop) — stills, tests, screenshots. */
746
+ settle(t) { this.renderFrame(t, 0); }
747
+ /** Stop the world. `releaseContext: true` also LOSES the GL context immediately —
748
+ * browsers cap live contexts per page and reclaim them only at GC, so many-canvas
749
+ * pages must release on teardown. A lost context is permanently dead on its canvas:
750
+ * a releasing caller must mount a FRESH canvas to build again (React: key it). */
751
+ dispose(releaseContext = false) {
752
+ this.disposed = true;
753
+ cancelAnimationFrame(this.raf);
754
+ this.raf = 0;
755
+ for (const c of this.cleanup)
756
+ c();
757
+ this.cleanup = [];
758
+ this.overlay.remove();
759
+ this.post?.dispose();
760
+ if (releaseContext)
761
+ this.gl.getExtension("WEBGL_lose_context")?.loseContext();
762
+ }
763
+ }
764
+ export function createWorld(o) { return new World(o); }