@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/LICENSE +36 -0
- package/README.md +80 -0
- package/dist/geometry.d.ts +40 -0
- package/dist/geometry.js +203 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/math.d.ts +45 -0
- package/dist/math.js +151 -0
- package/dist/post.d.ts +57 -0
- package/dist/post.js +213 -0
- package/dist/shaders.d.ts +17 -0
- package/dist/shaders.js +307 -0
- package/dist/world.d.ts +263 -0
- package/dist/world.js +764 -0
- package/package.json +44 -0
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); }
|