@chromatic-coherence/generative-charts-3d 0.12.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 ADDED
@@ -0,0 +1,36 @@
1
+ Chromatic Coherence — dark-math Licence (2026)
2
+
3
+ Copyright (c) 2026 Chromatic Coherence. All rights reserved.
4
+
5
+ This software ("the Software") is source-available but is NOT open source.
6
+ It is licensed, not sold. Downloading or installing it (for example from npm)
7
+ grants no rights beyond those written below, and does not transfer ownership.
8
+
9
+ 1. FREE LICENCE — no cost, perpetual — granted to:
10
+ * individuals, for personal or non-commercial use;
11
+ * registered charities and non-profit organisations;
12
+ * small businesses — an organisation with fewer than 25 people AND under
13
+ £1,000,000 in annual gross revenue.
14
+ A Free Licence lets you install the Software and use it AS-IS, embedded
15
+ inside your own application, and distribute it only as an inseparable part
16
+ of that application.
17
+
18
+ 2. COMMERCIAL LICENCE — required by any organisation not covered by clause 1
19
+ (an "SME or Enterprise"). A one-off fee of £100 per organisation grants a
20
+ perpetual Commercial Licence covering all of that organisation's products.
21
+ Contact hello@chromaticcoherence.ai.
22
+
23
+ 3. In no case, and under no licence, may you:
24
+ * modify, adapt, translate, or create derivative works of the Software;
25
+ * reverse-engineer, decompile, or disassemble it, except to the extent
26
+ that right cannot lawfully be excluded;
27
+ * resell, relicense, sublicense, rent, or redistribute the Software on its
28
+ own — as a package, fork, component, or competing library;
29
+ * remove or alter this licence or any copyright or authorship notice.
30
+
31
+ 4. NO WARRANTY. The Software is provided "AS IS", without warranty of any kind,
32
+ express or implied. To the fullest extent permitted by law, Chromatic
33
+ Coherence accepts no liability for any claim, damage, or loss arising from
34
+ the Software or its use.
35
+
36
+ For commercial licensing, contact hello@chromaticcoherence.ai.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # @chromatic-coherence/generative-charts-3d
2
+
3
+ **The second generative-charts engine.** The same API as
4
+ [generative-charts-2d](https://chromaticcoherence.ai/generative-charts) — `createWorld`, `line`,
5
+ `glow`, `face`, `grow`, the `draw*` living verbs, `cam`, `light`, `lights`, `pick` — rendered on
6
+ WebGL. Zero dependencies; the browser is still the whole runtime.
7
+
8
+ > **Status: v0.12.0 — the GPU edition, alpha.** What the GPU gives that canvas could not:
9
+ > a **real depth buffer** — occlusion is exact and always on, for solids *and* for light
10
+ > (a lamp behind a hill is hidden, no flags needed) — and **per-pixel light**: Lambert +
11
+ > point-light falloff evaluated in the fragment shader, so light sweeps smoothly ACROSS a
12
+ > face instead of flat-shading it. Deferred honestly: shadow maps, mirrors, `fade()` groups.
13
+ > Labels ride a synced 2D overlay (never occluded, as in 2d). Browser-only.
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import { createWorld, v3 } from "@chromatic-coherence/generative-charts-3d";
19
+
20
+ const w = createWorld({ canvas, controls: true });
21
+ w.light.signed = true;
22
+ w.lights.push({ p: v3(0, 150, 0), intensity: 0.9, falloff: 300 });
23
+ w.face(v3(-100, 0, -100), v3(100, 0, -100), v3(0, 0, 100), { hue: 265, sat: 40, light: 32 });
24
+ w.grow((t, dt, world) => world.drawGlow(v3(Math.cos(t) * 80, 30, Math.sin(t) * 80), 40, 12, 0.9));
25
+ w.start();
26
+ ```
27
+
28
+ Scenes written for generative-charts-2d largely run unchanged — same camera, same verbs, same payloads.
29
+
30
+ ## Licence
31
+
32
+ **Source-available, not open source.** Free for individuals, charities and small businesses
33
+ (fewer than 25 people and under £1M annual revenue); a one-off **£100** covers any larger
34
+ organisation. Install and use it as-is, embedded in your app — you may not modify it or
35
+ redistribute it on its own. Full terms: [LICENSE](./LICENSE).
36
+
37
+ © 2026 Chromatic Coherence. All rights reserved.
@@ -0,0 +1,196 @@
1
+ export type V3 = {
2
+ x: number;
3
+ y: number;
4
+ z: number;
5
+ };
6
+ export declare const v3: (x?: number, y?: number, z?: number) => V3;
7
+ export declare const add: (a: V3, b: V3) => V3;
8
+ export declare const sub: (a: V3, b: V3) => V3;
9
+ export declare const scale: (a: V3, s: number) => V3;
10
+ export declare const dot: (a: V3, b: V3) => number;
11
+ export declare const cross: (a: V3, b: V3) => V3;
12
+ export declare const len: (a: V3) => number;
13
+ export declare const norm: (a: V3) => V3;
14
+ export declare const TAU: number;
15
+ export declare const clamp: (v: number, lo: number, hi: number) => number;
16
+ export declare const lerp: (a: number, b: number, u: number) => number;
17
+ export declare const easeInOut: (u: number) => number;
18
+ export declare function rnd(i: number, k: number): number;
19
+ export type LineOpts = {
20
+ hue: number;
21
+ sat?: number;
22
+ light?: number;
23
+ alpha: number;
24
+ group?: string;
25
+ };
26
+ export type GlowOpts = {
27
+ hue?: number;
28
+ white?: boolean;
29
+ size: number;
30
+ alpha: number;
31
+ group?: string;
32
+ };
33
+ export type FaceOpts = {
34
+ hue: number;
35
+ sat?: number;
36
+ light?: number;
37
+ alpha?: number;
38
+ group?: string;
39
+ /** v0.5 — per-face material gloss 0..1 (multiplies the specular): skin ~0.5, cloth ~0.05. */
40
+ gloss?: number;
41
+ /** v0.8 — per-face perfusion 0..1: blood near the surface. Ruddiness + red subsurface bleed;
42
+ * the world's bloodPulse rides on it. Skin ~0.1; a worked muscle flushes higher. */
43
+ blood?: number;
44
+ };
45
+ export type PointLight = {
46
+ p: V3;
47
+ intensity: number;
48
+ falloff: number;
49
+ };
50
+ export type Projected = {
51
+ x: number;
52
+ y: number;
53
+ s: number;
54
+ z: number;
55
+ };
56
+ export type GrowFn = (t: number, dt: number, w: World) => void;
57
+ export type WorldOpts = {
58
+ canvas: HTMLCanvasElement;
59
+ maxDpr?: number;
60
+ drift?: number;
61
+ parallax?: number;
62
+ controls?: boolean;
63
+ shadows?: boolean;
64
+ camera?: {
65
+ target?: V3;
66
+ dist?: number;
67
+ pitch?: number;
68
+ yaw?: number;
69
+ fov?: number;
70
+ };
71
+ };
72
+ export declare class World {
73
+ private canvas;
74
+ private gl;
75
+ private overlay;
76
+ private og;
77
+ private opts;
78
+ private pFace;
79
+ private pLine;
80
+ private pPt;
81
+ private pDepth;
82
+ private shadowsOn;
83
+ private shadowFBO;
84
+ private shadowTex;
85
+ private svp;
86
+ private sFace;
87
+ private sLine;
88
+ private sPt;
89
+ private dFaceA;
90
+ private dLineA;
91
+ private dPtA;
92
+ private labels;
93
+ private bFace;
94
+ private bLine;
95
+ private bPt;
96
+ private bFaceD;
97
+ private bLineD;
98
+ private bPtD;
99
+ private staticDirty;
100
+ private hooks;
101
+ private raf;
102
+ private t0;
103
+ private tPrev;
104
+ private disposed;
105
+ private cleanup;
106
+ private fLight;
107
+ cam: {
108
+ target: V3;
109
+ dist: number;
110
+ pitch: number;
111
+ yawBase: number;
112
+ yawUser: number;
113
+ pitchUser: number;
114
+ distT: number;
115
+ fov: number;
116
+ };
117
+ light: {
118
+ dir: V3;
119
+ ambient: number;
120
+ diffuse: number;
121
+ signed: boolean;
122
+ };
123
+ lights: PointLight[];
124
+ /** v0.2 — per-pixel specular strength (0 = matte). Skin catches the light around 0.5. */
125
+ specular: number;
126
+ /** v0.5 — the ambient's colour, above and below (white = neutral, as before). */
127
+ ambientSky: V3;
128
+ ambientGround: V3;
129
+ /** v0.7 — the film look: filmic tone mapping on the solids (highlights roll off). */
130
+ filmic: boolean;
131
+ /** v0.12 — the colour grade: violet-cool shadows, warm highlights on the solids (art-directed). */
132
+ grade: boolean;
133
+ /** v0.12 — a cinematic vignette 0..1: edge-darkening on the composite, drawn on the overlay. */
134
+ vignette: number;
135
+ /** v0.8 — the global heartbeat: a scene sets this 0..1 each frame; it rides the per-face
136
+ * blood so all perfused skin flushes together with the pulse. */
137
+ bloodPulse: number;
138
+ private groupIdx;
139
+ private groupAlpha;
140
+ private groupTarget;
141
+ private idxFor;
142
+ /** Fade a named group toward a target alpha — eased per frame, exactly as in 2d. */
143
+ fade(name: string, target: number): void;
144
+ W: number;
145
+ H: number;
146
+ private mx;
147
+ private my;
148
+ private mxT;
149
+ private myT;
150
+ private yawUserT;
151
+ private pitchUserT;
152
+ private cx;
153
+ private rt;
154
+ private up;
155
+ private fw;
156
+ private focal;
157
+ private vp;
158
+ constructor(o: WorldOpts);
159
+ private wireControls;
160
+ private resize;
161
+ private pushFace;
162
+ /** v0.2 — pass per-vertex normals (na, nb, nc) and shading interpolates SMOOTHLY across
163
+ * the face: the facets vanish. Omit them for flat shading, exactly as before. */
164
+ face(a: V3, b: V3, c: V3, o: FaceOpts, na?: V3, nb?: V3, nc?: V3): void;
165
+ line(a: V3, b: V3, o: LineOpts): void;
166
+ glow(p: V3, o: GlowOpts): void;
167
+ dust(p: V3, alpha: number): void;
168
+ grow(fn: GrowFn): void;
169
+ drawLine(a: V3, b: V3, color: string, alpha: number, width?: number): void;
170
+ drawPath(pts: {
171
+ p: V3;
172
+ }[], color: string, alpha: number, width?: number): void;
173
+ drawGlow(p: V3, hue: number | null, size: number, alpha: number): void;
174
+ drawLabel(p: V3, text: string, color: string, alpha: number, dy?: number): void;
175
+ drawFace(a: V3, b: V3, c: V3, o: FaceOpts, na?: V3, nb?: V3, nc?: V3): void;
176
+ /** Screen-space overlay (the 2D label canvas — never occluded, like 2d's overlay()). */
177
+ overlay2d(cb: (g: CanvasRenderingContext2D, w: World) => void): void;
178
+ private setupCamera;
179
+ project(p: V3): Projected | null;
180
+ fog(z: number): number;
181
+ pick(sx: number, sy: number, planeY?: number): V3 | null;
182
+ get surface(): HTMLCanvasElement;
183
+ private renderFrame;
184
+ private setVP;
185
+ private setFog;
186
+ private setLights;
187
+ private attrib;
188
+ private depthDraw;
189
+ private drawFaces;
190
+ private drawLines;
191
+ private drawPts;
192
+ start(): void;
193
+ settle(t: number): void;
194
+ dispose(): void;
195
+ }
196
+ export declare function createWorld(o: WorldOpts): World;
package/dist/index.js ADDED
@@ -0,0 +1,771 @@
1
+ // dark-math-3d — THE SECOND ENGINE. The same API as dark-math-2d, rendered on WebGL.
2
+ //
3
+ // Zero third-party imports, on purpose. What the GPU gives that canvas could not:
4
+ // • a REAL DEPTH BUFFER — occlusion is exact and always on, for solids AND for light
5
+ // (glows and lines are depth-tested against the faces: a lamp behind a hill is hidden)
6
+ // • PER-PIXEL LIGHT — Lambert + point-light falloff evaluated in the fragment shader,
7
+ // so light sweeps smoothly ACROSS a face instead of flat-shading it
8
+ // v0.1 first light · v0.2 smooth normals + specular · v0.3 real shadow maps (PCF)
9
+ // · v0.4 fade() groups · v0.5 materials (gloss + coloured ambient) · v0.6 soft shadows
10
+ // · v0.7 filmic tone map · v0.8 THE PERFUSION TERM — blood near the surface changes how it
11
+ // meets the light (ruddiness + red subsurface bleed + the global pulse). The frontier:
12
+ // effort is fluid moving, and fluid moving is visible. Deferred: mirrors.
13
+ export const v3 = (x = 0, y = 0, z = 0) => ({ x, y, z });
14
+ export const add = (a, b) => v3(a.x + b.x, a.y + b.y, a.z + b.z);
15
+ export const sub = (a, b) => v3(a.x - b.x, a.y - b.y, a.z - b.z);
16
+ export const scale = (a, s) => v3(a.x * s, a.y * s, a.z * s);
17
+ export const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
18
+ export const cross = (a, b) => v3(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x);
19
+ export const len = (a) => Math.hypot(a.x, a.y, a.z);
20
+ export const norm = (a) => { const l = len(a) || 1; return v3(a.x / l, a.y / l, a.z / l); };
21
+ export const TAU = Math.PI * 2;
22
+ export const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
23
+ export const lerp = (a, b, u) => a + (b - a) * u;
24
+ export const easeInOut = (u) => (u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2);
25
+ export function rnd(i, k) {
26
+ const x = Math.sin(i * 12.9898 + k * 78.233) * 43758.5453;
27
+ return x - Math.floor(x);
28
+ }
29
+ // hsl → rgb (0..1), CPU-side — colours ride the vertex buffers
30
+ function hsl(h, s, l) {
31
+ h = (((h % 360) + 360) % 360) / 360;
32
+ s = clamp(s, 0, 100) / 100;
33
+ l = clamp(l, 0, 100) / 100;
34
+ if (s === 0)
35
+ return [l, l, l];
36
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q;
37
+ const f = (t) => {
38
+ t = ((t % 1) + 1) % 1;
39
+ if (t < 1 / 6)
40
+ return p + (q - p) * 6 * t;
41
+ if (t < 1 / 2)
42
+ return q;
43
+ if (t < 2 / 3)
44
+ return p + (q - p) * (2 / 3 - t) * 6;
45
+ return p;
46
+ };
47
+ return [f(h + 1 / 3), f(h), f(h - 1 / 3)];
48
+ }
49
+ function mPerspective(fovy, aspect, near, far) {
50
+ const f = 1 / Math.tan(fovy / 2), nf = 1 / (near - far);
51
+ const m = new Float32Array(16);
52
+ m[0] = f / aspect;
53
+ m[5] = f;
54
+ m[10] = (far + near) * nf;
55
+ m[11] = -1;
56
+ m[14] = 2 * far * near * nf;
57
+ return m;
58
+ }
59
+ function mLookAt(eye, at, up) {
60
+ const zc = norm(sub(eye, at)), xc = norm(cross(up, zc)), yc = cross(zc, xc);
61
+ const m = new Float32Array(16);
62
+ m[0] = xc.x;
63
+ m[1] = yc.x;
64
+ m[2] = zc.x;
65
+ m[4] = xc.y;
66
+ m[5] = yc.y;
67
+ m[6] = zc.y;
68
+ m[8] = xc.z;
69
+ m[9] = yc.z;
70
+ m[10] = zc.z;
71
+ m[12] = -dot(xc, eye);
72
+ m[13] = -dot(yc, eye);
73
+ m[14] = -dot(zc, eye);
74
+ m[15] = 1;
75
+ return m;
76
+ }
77
+ function mMul(a, b) {
78
+ const o = new Float32Array(16);
79
+ for (let c = 0; c < 4; c++)
80
+ for (let r = 0; r < 4; r++)
81
+ o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
82
+ return o;
83
+ }
84
+ const FACE_VS = `
85
+ attribute vec3 aPos; attribute vec3 aNorm; attribute vec4 aCol; attribute float aGrp; attribute float aBlood;
86
+ uniform mat4 uVP; uniform mat4 uSVP; uniform float uGA[8];
87
+ varying vec3 vPos; varying vec3 vNorm; varying vec4 vCol; varying float vZ; varying vec4 vSh; varying float vGA; varying float vBlood;
88
+ void main(){ vPos=aPos; vNorm=aNorm; vCol=aCol; vGA=uGA[int(aGrp+0.5)]; vBlood=aBlood;
89
+ vec4 p=uVP*vec4(aPos,1.0); vZ=p.w; vSh=uSVP*vec4(aPos,1.0); gl_Position=p; }`;
90
+ const DEPTH_VS = `
91
+ attribute vec3 aPos; uniform mat4 uVP; varying float vD;
92
+ void main(){ vec4 p=uVP*vec4(aPos,1.0); vD=p.z/p.w*0.5+0.5; gl_Position=p; }`;
93
+ const DEPTH_FS = `
94
+ precision mediump float; varying float vD;
95
+ vec4 pack(float d){ vec4 e=fract(d*vec4(1.,255.,65025.,16581375.)); return e-e.yzww*vec4(1./255.,1./255.,1./255.,0.); }
96
+ void main(){ gl_FragColor=pack(clamp(vD,0.,1.)); }`;
97
+ const FACE_FS = `
98
+ precision mediump float;
99
+ varying vec3 vPos; varying vec3 vNorm; varying vec4 vCol; varying float vZ;
100
+ uniform vec3 uLdir; uniform float uAmb; uniform float uDif; uniform float uSigned;
101
+ uniform vec3 uLp[4]; uniform float uLi[4]; uniform float uLf[4]; uniform int uLn;
102
+ uniform vec3 uCam; uniform float uFogK; uniform float uSpec;
103
+ uniform vec3 uSkyC; uniform vec3 uGroundC; uniform float uFilm; uniform float uGrade;
104
+ uniform float uShadowOn; uniform sampler2D uShadow; uniform float uPulse;
105
+ varying vec4 vSh; varying float vGA; varying float vBlood;
106
+ float unpack(vec4 e){ return dot(e, vec4(1., 1./255., 1./65025., 1./16581375.)); }
107
+ float h3(vec3 p){ return fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453); }
108
+ float vnoise(vec3 p){ vec3 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
109
+ return mix(mix(mix(h3(i), h3(i + vec3(1,0,0)), f.x), mix(h3(i + vec3(0,1,0)), h3(i + vec3(1,1,0)), f.x), f.y),
110
+ mix(mix(h3(i + vec3(0,0,1)), h3(i + vec3(1,0,1)), f.x), mix(h3(i + vec3(0,1,1)), h3(i + vec3(1,1,1)), f.x), f.y), f.z); }
111
+ float shadowAt(vec2 uv, float ref){
112
+ if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return 1.0;
113
+ return ref - 0.0035 <= unpack(texture2D(uShadow, uv)) ? 1.0 : 0.0;
114
+ }
115
+ void main(){
116
+ vec3 n = normalize(vNorm);
117
+ vec3 V = normalize(uCam - vPos);
118
+ float skin0 = clamp(vBlood * 5.0, 0.0, 1.0);
119
+ // v0.11 — a PROCEDURAL NORMAL MAP: perturb the shading normal by the gradient of a fine
120
+ // noise field, so the surface catches light as if it had micro-relief (pores, weave) —
121
+ // the trick real engines get from baked normal maps, done in pure math.
122
+ {
123
+ float bs = 5.5, e = 0.12, n0 = vnoise(vPos * bs);
124
+ vec3 g = vec3(vnoise(vPos * bs + vec3(e, 0.0, 0.0)) - n0,
125
+ vnoise(vPos * bs + vec3(0.0, e, 0.0)) - n0,
126
+ vnoise(vPos * bs + vec3(0.0, 0.0, e)) - n0) / e;
127
+ n = normalize(n - g * (0.10 + 0.30 * skin0));
128
+ }
129
+ float d = dot(n, normalize(uLdir));
130
+ float shade = uAmb + uDif * mix(abs(d), max(d, 0.0), uSigned);
131
+ float spec = 0.0;
132
+ float sh = 1.0;
133
+ if (uShadowOn > 0.5) { // lights[0] casts — 3x3 PCF for genuinely soft penumbrae (v0.6)
134
+ vec3 pr = vSh.xyz / vSh.w * 0.5 + 0.5;
135
+ float px = 1.6 / 1024.0;
136
+ sh = 0.0;
137
+ for (int sy = -1; sy <= 1; sy++)
138
+ for (int sx = -1; sx <= 1; sx++)
139
+ sh += shadowAt(pr.xy + vec2(float(sx), float(sy)) * px, pr.z);
140
+ sh /= 9.0;
141
+ sh = 0.35 + 0.65 * sh;
142
+ }
143
+ for (int i = 0; i < 4; i++) { if (i >= uLn) break;
144
+ vec3 v = uLp[i] - vPos; float d2 = dot(v, v); vec3 L = v * inversesqrt(d2 + 1e-4);
145
+ float lam = dot(n, L); lam = mix(abs(lam), max(lam, 0.0), uSigned);
146
+ float fall = 1.0 / (1.0 + d2 / (uLf[i] * uLf[i]));
147
+ float k = (i == 0) ? sh : 1.0;
148
+ shade += uLi[i] * lam * fall * k;
149
+ // v0.11 — GGX microfacet specular + Fresnel: roughness from the face's gloss (alpha slot),
150
+ // so skin gives a tight bright highlight and cloth a broad dull sheen — real material response.
151
+ vec3 H = normalize(L + V);
152
+ float nh = max(dot(n, H), 0.0);
153
+ float rough = clamp(1.0 - vCol.a, 0.08, 1.0), a2 = rough * rough; a2 = a2 * a2;
154
+ float dnm = nh * nh * (a2 - 1.0) + 1.0;
155
+ float ggx = a2 / (3.14159 * dnm * dnm);
156
+ float fres = 0.04 + 0.96 * pow(1.0 - max(dot(H, V), 0.0), 5.0);
157
+ spec += uLi[i] * min(ggx, 6.0) * fres * fall * k * 0.16;
158
+ }
159
+ // v0.5 — the ambient has a COLOUR: cool from above, warm from below (the room bounces)
160
+ vec3 tint = mix(uGroundC, uSkyC, n.y * 0.5 + 0.5);
161
+ vec3 lit = vCol.rgb * (uAmb * tint + vec3(shade - uAmb));
162
+ // v0.8 — THE PERFUSION TERM: blood near the surface changes how it meets the light.
163
+ // the global pulse rides on the per-face blood; ruddiness where it pools, and a red
164
+ // subsurface bleed strongest at the terminator (where light grazes thin tissue).
165
+ float b = clamp(vBlood + uPulse * vBlood * 6.0, 0.0, 1.0);
166
+ lit *= mix(vec3(1.0), vec3(1.07, 0.95, 0.94), b); // the flush
167
+ lit += vec3(0.62, 0.06, 0.08) * (b * 0.20 * (1.0 - abs(d))); // the subsurface red at the terminator
168
+ // v0.9 — skin micro-texture: fine pore noise, only where there is blood (skin, not cloth)
169
+ float skin = clamp(vBlood * 5.0, 0.0, 1.0);
170
+ float pore = vnoise(vPos * 2.4) * 0.6 + vnoise(vPos * 6.0) * 0.4;
171
+ lit *= 1.0 + (pore - 0.5) * 0.09 * skin;
172
+ // v0.10 — subsurface RIM: at grazing angles skin is thin and light bleeds through red
173
+ // (the ears, the nose, fingers seen edge-on glow warm — the deepest skin cue there is)
174
+ float fres = pow(1.0 - clamp(dot(n, V), 0.0, 1.0), 3.0);
175
+ lit += vec3(0.55, 0.09, 0.10) * fres * skin * (0.28 + 0.5 * b);
176
+ float fog = clamp(uFogK / vZ, 0.08, 1.0);
177
+ vec3 outC = (lit + vec3(1.0) * spec * uSpec * vCol.a) * fog * vGA;
178
+ if (uFilm > 0.5) { // v0.7 — the film look: highlights roll off instead of clipping
179
+ outC = (outC * (2.51 * outC + 0.03)) / (outC * (2.43 * outC + 0.59) + 0.14);
180
+ }
181
+ if (uGrade > 0.5) { // v0.12 — the colour grade: violet-cool shadows, warm highlights (art-directed)
182
+ float luma = dot(outC, vec3(0.299, 0.587, 0.114));
183
+ outC *= mix(vec3(0.85, 0.83, 1.07), vec3(1.07, 0.99, 0.92), smoothstep(0.12, 0.72, luma));
184
+ outC = clamp(outC, 0.0, 1.0);
185
+ }
186
+ gl_FragColor = vec4(outC, 1.0);
187
+ }`;
188
+ const LINE_VS = `
189
+ attribute vec3 aPos; attribute vec4 aCol; attribute float aGrp;
190
+ uniform mat4 uVP; uniform float uGA[8];
191
+ varying vec4 vCol; varying float vZ; varying float vGA;
192
+ void main(){ vCol=aCol; vGA=uGA[int(aGrp+0.5)]; vec4 p=uVP*vec4(aPos,1.0); vZ=p.w; gl_Position=p; }`;
193
+ const LINE_FS = `
194
+ precision mediump float; varying vec4 vCol; varying float vZ; varying float vGA; uniform float uFogK;
195
+ void main(){ float fog = clamp(uFogK / vZ, 0.08, 1.0); gl_FragColor = vec4(vCol.rgb * fog, vCol.a * fog * vGA); }`;
196
+ const PT_VS = `
197
+ attribute vec3 aPos; attribute float aSize; attribute vec4 aCol; attribute float aGrp;
198
+ uniform mat4 uVP; uniform float uFocal; uniform float uGA[8];
199
+ varying vec4 vCol; varying float vZ; varying float vGA;
200
+ void main(){ vCol=aCol; vGA=uGA[int(aGrp+0.5)]; vec4 p=uVP*vec4(aPos,1.0); vZ=p.w;
201
+ gl_PointSize = clamp(aSize * uFocal / p.w, 1.0, 256.0); gl_Position=p; }`;
202
+ const PT_FS = `
203
+ precision mediump float; varying vec4 vCol; varying float vZ; varying float vGA; uniform float uFogK;
204
+ void main(){
205
+ float r = length(gl_PointCoord - 0.5) * 2.0;
206
+ float a = exp(-3.2 * r * r) * (1.0 - smoothstep(0.85, 1.0, r));
207
+ float fog = clamp(uFogK / vZ, 0.08, 1.0);
208
+ gl_FragColor = vec4(vCol.rgb, 1.0) * (vCol.a * a * fog * vGA);
209
+ }`;
210
+ function makeProg(gl, vs, fs) {
211
+ const sh = (type, src) => {
212
+ const s = gl.createShader(type);
213
+ gl.shaderSource(s, src);
214
+ gl.compileShader(s);
215
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
216
+ throw new Error("dark-math-3d shader: " + gl.getShaderInfoLog(s));
217
+ return s;
218
+ };
219
+ const p = gl.createProgram();
220
+ gl.attachShader(p, sh(gl.VERTEX_SHADER, vs));
221
+ gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs));
222
+ gl.linkProgram(p);
223
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS))
224
+ throw new Error("dark-math-3d link: " + gl.getProgramInfoLog(p));
225
+ return p;
226
+ }
227
+ /** Parse a css hsl(...) string the way the 2d examples write them. */
228
+ function parseHsl(c) {
229
+ const m = /hsl\(\s*([\d.]+)[deg\s]*[, ]\s*([\d.]+)%[, ]\s*([\d.]+)%/.exec(c);
230
+ return m ? hsl(+m[1], +m[2], +m[3]) : [0.9, 0.9, 1];
231
+ }
232
+ export class World {
233
+ idxFor(name) {
234
+ const n = name ?? "default";
235
+ let i = this.groupIdx.get(n);
236
+ if (i == null) {
237
+ i = Math.min(7, this.groupIdx.size);
238
+ this.groupIdx.set(n, i);
239
+ }
240
+ return i;
241
+ }
242
+ /** Fade a named group toward a target alpha — eased per frame, exactly as in 2d. */
243
+ fade(name, target) { this.groupTarget[this.idxFor(name)] = clamp(target, 0, 1); }
244
+ constructor(o) {
245
+ this.shadowFBO = null;
246
+ this.shadowTex = null;
247
+ this.svp = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
248
+ this.sFace = [];
249
+ this.sLine = [];
250
+ this.sPt = [];
251
+ this.dFaceA = [];
252
+ this.dLineA = [];
253
+ this.dPtA = [];
254
+ this.labels = [];
255
+ this.staticDirty = true;
256
+ this.hooks = [];
257
+ this.raf = 0;
258
+ this.t0 = 0;
259
+ this.tPrev = 0;
260
+ this.disposed = false;
261
+ this.cleanup = [];
262
+ this.fLight = [];
263
+ this.cam = { target: v3(0, 0, 0), dist: 700, pitch: 0.5, yawBase: 0, yawUser: 0, pitchUser: 0, distT: 700, fov: 1.15 };
264
+ this.light = { dir: v3(0, 1, 0), ambient: 0.55, diffuse: 0.45, signed: false };
265
+ this.lights = [];
266
+ /** v0.2 — per-pixel specular strength (0 = matte). Skin catches the light around 0.5. */
267
+ this.specular = 0;
268
+ /** v0.5 — the ambient's colour, above and below (white = neutral, as before). */
269
+ this.ambientSky = v3(1, 1, 1);
270
+ this.ambientGround = v3(1, 1, 1);
271
+ /** v0.7 — the film look: filmic tone mapping on the solids (highlights roll off). */
272
+ this.filmic = false;
273
+ /** v0.12 — the colour grade: violet-cool shadows, warm highlights on the solids (art-directed). */
274
+ this.grade = false;
275
+ /** v0.12 — a cinematic vignette 0..1: edge-darkening on the composite, drawn on the overlay. */
276
+ this.vignette = 0;
277
+ /** v0.8 — the global heartbeat: a scene sets this 0..1 each frame; it rides the per-face
278
+ * blood so all perfused skin flushes together with the pulse. */
279
+ this.bloodPulse = 0;
280
+ // v0.4 — fade(): named groups with eased alphas, resolved in the shaders (max 8 groups)
281
+ this.groupIdx = new Map([["default", 0]]);
282
+ this.groupAlpha = new Float32Array(8).fill(1);
283
+ this.groupTarget = new Float32Array(8).fill(1);
284
+ this.W = 0;
285
+ this.H = 0;
286
+ this.mx = 0;
287
+ this.my = 0;
288
+ this.mxT = 0;
289
+ this.myT = 0;
290
+ this.yawUserT = 0;
291
+ this.pitchUserT = 0;
292
+ this.cx = v3();
293
+ this.rt = v3();
294
+ this.up = v3();
295
+ this.fw = v3();
296
+ this.focal = 0;
297
+ this.vp = new Float32Array(16);
298
+ this.canvas = o.canvas;
299
+ const gl = o.canvas.getContext("webgl", { alpha: true, antialias: true, premultipliedAlpha: true });
300
+ if (!gl)
301
+ throw new Error("dark-math-3d: no webgl context");
302
+ this.gl = gl;
303
+ this.opts = {
304
+ maxDpr: o.maxDpr ?? 2,
305
+ drift: o.drift ?? 0.045,
306
+ parallax: o.parallax ?? 0.3,
307
+ controls: o.controls ?? false,
308
+ };
309
+ if (o.camera) {
310
+ if (o.camera.target)
311
+ this.cam.target = o.camera.target;
312
+ if (o.camera.dist != null) {
313
+ this.cam.dist = o.camera.dist;
314
+ this.cam.distT = o.camera.dist;
315
+ }
316
+ if (o.camera.pitch != null)
317
+ this.cam.pitch = o.camera.pitch;
318
+ if (o.camera.yaw != null)
319
+ this.cam.yawBase = o.camera.yaw;
320
+ if (o.camera.fov != null)
321
+ this.cam.fov = o.camera.fov;
322
+ }
323
+ this.pFace = makeProg(gl, FACE_VS, FACE_FS);
324
+ this.pLine = makeProg(gl, LINE_VS, LINE_FS);
325
+ this.pPt = makeProg(gl, PT_VS, PT_FS);
326
+ this.pDepth = makeProg(gl, DEPTH_VS, DEPTH_FS);
327
+ this.shadowsOn = o.shadows ?? false;
328
+ if (this.shadowsOn) {
329
+ this.shadowTex = gl.createTexture();
330
+ gl.bindTexture(gl.TEXTURE_2D, this.shadowTex);
331
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1024, 1024, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
332
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
333
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
334
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
335
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
336
+ const rb = gl.createRenderbuffer();
337
+ gl.bindRenderbuffer(gl.RENDERBUFFER, rb);
338
+ gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, 1024, 1024);
339
+ this.shadowFBO = gl.createFramebuffer();
340
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFBO);
341
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.shadowTex, 0);
342
+ gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, rb);
343
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
344
+ }
345
+ this.bFace = gl.createBuffer();
346
+ this.bLine = gl.createBuffer();
347
+ this.bPt = gl.createBuffer();
348
+ this.bFaceD = gl.createBuffer();
349
+ this.bLineD = gl.createBuffer();
350
+ this.bPtD = gl.createBuffer();
351
+ // the label overlay — a synced 2D canvas floated over the GL surface
352
+ this.overlay = document.createElement("canvas");
353
+ this.overlay.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;";
354
+ const parent = this.canvas.parentElement;
355
+ if (parent) {
356
+ if (getComputedStyle(parent).position === "static")
357
+ parent.style.position = "relative";
358
+ parent.appendChild(this.overlay);
359
+ }
360
+ this.og = this.overlay.getContext("2d");
361
+ this.resize();
362
+ const ro = new ResizeObserver(() => this.resize());
363
+ ro.observe(this.canvas);
364
+ this.cleanup.push(() => ro.disconnect());
365
+ const onMouse = (e) => {
366
+ this.mxT = (e.clientX / window.innerWidth - 0.5) * 2;
367
+ this.myT = (e.clientY / window.innerHeight - 0.5) * 2;
368
+ };
369
+ window.addEventListener("mousemove", onMouse);
370
+ this.cleanup.push(() => window.removeEventListener("mousemove", onMouse));
371
+ if (this.opts.controls)
372
+ this.wireControls();
373
+ }
374
+ wireControls() {
375
+ const cv = this.canvas;
376
+ cv.style.touchAction = "pan-y";
377
+ cv.style.cursor = "grab";
378
+ let dragging = false, lx = 0, ly = 0;
379
+ const down = (e) => { dragging = true; lx = e.clientX; ly = e.clientY; cv.style.cursor = "grabbing"; cv.setPointerCapture(e.pointerId); };
380
+ const move = (e) => {
381
+ if (!dragging)
382
+ return;
383
+ this.yawUserT -= (e.clientX - lx) * 0.0045;
384
+ this.pitchUserT = clamp(this.pitchUserT + (e.clientY - ly) * 0.003, -0.3, 0.4);
385
+ lx = e.clientX;
386
+ ly = e.clientY;
387
+ };
388
+ const upFn = () => { dragging = false; cv.style.cursor = "grab"; };
389
+ const wheel = (e) => {
390
+ if (!e.ctrlKey && !e.metaKey)
391
+ return;
392
+ e.preventDefault();
393
+ this.cam.distT = clamp(this.cam.distT + e.deltaY * 0.6, 120, 2200);
394
+ };
395
+ cv.addEventListener("pointerdown", down);
396
+ cv.addEventListener("pointermove", move);
397
+ cv.addEventListener("pointerup", upFn);
398
+ cv.addEventListener("pointercancel", upFn);
399
+ cv.addEventListener("wheel", wheel, { passive: false });
400
+ this.cleanup.push(() => {
401
+ cv.removeEventListener("pointerdown", down);
402
+ cv.removeEventListener("pointermove", move);
403
+ cv.removeEventListener("pointerup", upFn);
404
+ cv.removeEventListener("pointercancel", upFn);
405
+ cv.removeEventListener("wheel", wheel);
406
+ });
407
+ }
408
+ resize() {
409
+ const dpr = Math.min(window.devicePixelRatio || 1, this.opts.maxDpr);
410
+ this.W = this.canvas.clientWidth;
411
+ this.H = this.canvas.clientHeight;
412
+ this.canvas.width = Math.max(1, Math.round(this.W * dpr));
413
+ this.canvas.height = Math.max(1, Math.round(this.H * dpr));
414
+ this.overlay.width = this.canvas.width;
415
+ this.overlay.height = this.canvas.height;
416
+ this.og.setTransform(dpr, 0, 0, dpr, 0, 0);
417
+ this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
418
+ }
419
+ // ── building the static world (same verbs as 2d) ──
420
+ pushFace(store, a, b, c, o, na, nb, nc) {
421
+ const n = na ? undefined : norm(cross(sub(b, a), sub(c, a)));
422
+ const [r, g, bl] = hsl(o.hue, o.sat ?? 45, o.light ?? 30);
423
+ const al = clamp(o.alpha ?? 1, 0, 1);
424
+ const gi = this.idxFor(o.group);
425
+ // faces are opaque (blend off), so the colour's alpha slot carries the GLOSS — documented
426
+ const gloss = clamp(o.gloss ?? 0.3, 0, 1);
427
+ const blood = clamp(o.blood ?? 0, 0, 1);
428
+ void al;
429
+ const verts = [[a, na ?? n], [b, nb ?? n], [c, nc ?? n]];
430
+ for (const [p, nn] of verts)
431
+ store.push(p.x, p.y, p.z, nn.x, nn.y, nn.z, r, g, bl, gloss, blood, gi);
432
+ }
433
+ /** v0.2 — pass per-vertex normals (na, nb, nc) and shading interpolates SMOOTHLY across
434
+ * the face: the facets vanish. Omit them for flat shading, exactly as before. */
435
+ face(a, b, c, o, na, nb, nc) {
436
+ this.pushFace(this.sFace, a, b, c, o, na, nb, nc);
437
+ this.staticDirty = true;
438
+ }
439
+ line(a, b, o) {
440
+ const [r, g, bl] = hsl(o.hue, o.sat ?? 80, o.light ?? 55);
441
+ const al = clamp(o.alpha, 0, 1), gi = this.idxFor(o.group);
442
+ 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);
443
+ this.staticDirty = true;
444
+ }
445
+ glow(p, o) {
446
+ const [r, g, bl] = o.white ? [1, 1, 1] : hsl(o.hue ?? 0, 85, 62);
447
+ this.sPt.push(p.x, p.y, p.z, o.size, r, g, bl, clamp(o.alpha, 0, 1), this.idxFor(o.group));
448
+ this.staticDirty = true;
449
+ }
450
+ dust(p, alpha) {
451
+ this.sPt.push(p.x, p.y, p.z, 1.6, 0.87, 0.89, 1, clamp(alpha * 0.6, 0, 1), 0);
452
+ this.staticDirty = true;
453
+ }
454
+ grow(fn) { this.hooks.push(fn); }
455
+ // ── the living verbs (gathered per frame, exactly like 2d's two beats) ──
456
+ drawLine(a, b, color, alpha, width = 1) {
457
+ this.fLight.push({ k: "line", a, b, color, alpha, width });
458
+ }
459
+ drawPath(pts, color, alpha, width = 1) {
460
+ this.fLight.push({ k: "poly", pts, color, alpha, width });
461
+ }
462
+ drawGlow(p, hue, size, alpha) {
463
+ this.fLight.push({ k: "glow", p, hue, size, alpha });
464
+ }
465
+ drawLabel(p, text, color, alpha, dy = 0) {
466
+ this.fLight.push({ k: "label", p, text, color, alpha, dy });
467
+ }
468
+ drawFace(a, b, c, o, na, nb, nc) {
469
+ this.pushFace(this.dFaceA, a, b, c, o, na, nb, nc);
470
+ }
471
+ /** Screen-space overlay (the 2D label canvas — never occluded, like 2d's overlay()). */
472
+ overlay2d(cb) { cb(this.og, this); }
473
+ // ── camera (identical maths to 2d) + CPU projection for pick/labels ──
474
+ setupCamera(t, dt) {
475
+ this.mx += (this.mxT - this.mx) * Math.min(1, dt * 3);
476
+ this.my += (this.myT - this.my) * Math.min(1, dt * 3);
477
+ this.cam.yawUser += (this.yawUserT - this.cam.yawUser) * Math.min(1, dt * 5);
478
+ this.cam.pitchUser += (this.pitchUserT - this.cam.pitchUser) * Math.min(1, dt * 5);
479
+ this.cam.dist += (this.cam.distT - this.cam.dist) * Math.min(1, dt * 4);
480
+ const yaw = this.cam.yawBase + t * this.opts.drift + this.cam.yawUser + this.mx * this.opts.parallax;
481
+ const pitch = clamp(this.cam.pitch + this.cam.pitchUser + this.my * this.opts.parallax * 0.5, 0.18, 0.95);
482
+ const cp = Math.cos(pitch), sp = Math.sin(pitch);
483
+ this.cx = add(this.cam.target, scale(v3(cp * Math.cos(yaw), sp, cp * Math.sin(yaw)), this.cam.dist));
484
+ this.fw = norm(sub(this.cam.target, this.cx));
485
+ this.rt = norm(cross(this.fw, v3(0, 1, 0)));
486
+ this.up = cross(this.rt, this.fw);
487
+ this.focal = Math.min(this.W, this.H) * this.cam.fov;
488
+ const fovy = 2 * Math.atan(this.H / (2 * this.focal));
489
+ this.vp = mMul(mPerspective(fovy, this.W / this.H, 24, 6000), mLookAt(this.cx, this.cam.target, v3(0, 1, 0)));
490
+ }
491
+ project(p) {
492
+ const dx = p.x - this.cx.x, dy = p.y - this.cx.y, dz = p.z - this.cx.z;
493
+ const z = dx * this.fw.x + dy * this.fw.y + dz * this.fw.z;
494
+ if (z < 24)
495
+ return null;
496
+ const f = this.focal / z;
497
+ return {
498
+ x: this.W / 2 + (dx * this.rt.x + dy * this.rt.y + dz * this.rt.z) * f,
499
+ y: this.H / 2 - (dx * this.up.x + dy * this.up.y + dz * this.up.z) * f,
500
+ s: f, z,
501
+ };
502
+ }
503
+ fog(z) { return Math.min(1, Math.max(0.08, (this.cam.dist * 0.9) / z)); }
504
+ pick(sx, sy, planeY = 0) {
505
+ const rx = (sx - this.W / 2) / this.focal, ry = -(sy - this.H / 2) / this.focal;
506
+ const dir = norm(add(add(this.fw, scale(this.rt, rx)), scale(this.up, ry)));
507
+ if (Math.abs(dir.y) < 1e-5)
508
+ return null;
509
+ const s = (planeY - this.cx.y) / dir.y;
510
+ if (s < 24)
511
+ return null;
512
+ return add(this.cx, scale(dir, s));
513
+ }
514
+ get surface() { return this.canvas; }
515
+ // ── the frame: gather → render (depth does the occlusion; the shader does the light) ──
516
+ renderFrame(t, dt) {
517
+ const gl = this.gl;
518
+ this.setupCamera(t, dt);
519
+ this.fLight.length = 0;
520
+ this.dFaceA.length = 0;
521
+ this.dLineA.length = 0;
522
+ this.dPtA.length = 0;
523
+ for (const fn of this.hooks)
524
+ fn(t, dt, this);
525
+ this.labels.length = 0;
526
+ for (const rec of this.fLight) {
527
+ if (rec.k === "label") {
528
+ this.labels.push(rec);
529
+ continue;
530
+ }
531
+ if (rec.k === "glow") {
532
+ const [r, g, b] = rec.hue == null ? [1, 1, 1] : hsl(rec.hue, 85, 62);
533
+ this.dPtA.push(rec.p.x, rec.p.y, rec.p.z, rec.size, r, g, b, clamp(rec.alpha, 0, 1), 0);
534
+ }
535
+ else if (rec.k === "line") {
536
+ const [r, g, b] = parseHsl(rec.color);
537
+ 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);
538
+ }
539
+ else if (rec.k === "poly") {
540
+ const [r, g, b] = parseHsl(rec.color);
541
+ for (let i = 1; i < rec.pts.length; i++) {
542
+ const a = rec.pts[i - 1].p, q = rec.pts[i].p;
543
+ 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);
544
+ }
545
+ }
546
+ }
547
+ const fogK = this.cam.dist * 0.9;
548
+ for (let i = 0; i < 8; i++) // fade(): eased group alphas
549
+ this.groupAlpha[i] += (this.groupTarget[i] - this.groupAlpha[i]) * Math.min(1, dt * 6);
550
+ // upload face buffers first (the depth pass reads them too)
551
+ if (this.staticDirty) {
552
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.bFace);
553
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(this.sFace), gl.STATIC_DRAW);
554
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.bLine);
555
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(this.sLine), gl.STATIC_DRAW);
556
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.bPt);
557
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(this.sPt), gl.STATIC_DRAW);
558
+ this.staticDirty = false;
559
+ }
560
+ if (this.dFaceA.length) {
561
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.bFaceD);
562
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(this.dFaceA), gl.DYNAMIC_DRAW);
563
+ }
564
+ // v0.3 — the shadow pass: the world as lights[0] sees it, packed into a depth texture
565
+ if (this.shadowsOn && this.shadowFBO && this.lights.length) {
566
+ const lp = this.lights[0].p;
567
+ const dir = norm(sub(this.cam.target, lp));
568
+ const up = Math.abs(dir.y) > 0.95 ? v3(1, 0, 0) : v3(0, 1, 0);
569
+ this.svp = mMul(mPerspective(1.15, 1, 40, 3500), mLookAt(lp, this.cam.target, up));
570
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFBO);
571
+ gl.viewport(0, 0, 1024, 1024);
572
+ gl.clearColor(1, 1, 1, 1);
573
+ gl.enable(gl.DEPTH_TEST);
574
+ gl.depthMask(true);
575
+ gl.disable(gl.BLEND);
576
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
577
+ gl.useProgram(this.pDepth);
578
+ gl.uniformMatrix4fv(gl.getUniformLocation(this.pDepth, "uVP"), false, this.svp);
579
+ this.depthDraw(this.bFace, this.sFace.length / 12);
580
+ this.depthDraw(this.bFaceD, this.dFaceA.length / 12);
581
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
582
+ gl.viewport(0, 0, this.canvas.width, this.canvas.height);
583
+ }
584
+ gl.clearColor(0, 0, 0, 0);
585
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
586
+ // the solids — depth-written, per-pixel lit (and shadowed, when the map is on)
587
+ gl.enable(gl.DEPTH_TEST);
588
+ gl.depthMask(true);
589
+ gl.disable(gl.BLEND);
590
+ gl.useProgram(this.pFace);
591
+ this.setVP(this.pFace);
592
+ this.setLights(this.pFace, fogK);
593
+ this.drawFaces(this.bFace, this.sFace.length / 12, null);
594
+ if (this.dFaceA.length)
595
+ this.drawFaces(this.bFaceD, this.dFaceA.length / 12, null);
596
+ // the light — additive, depth-TESTED but not written: the dark occludes the light, exactly
597
+ gl.depthMask(false);
598
+ gl.enable(gl.BLEND);
599
+ gl.blendFunc(gl.ONE, gl.ONE);
600
+ gl.useProgram(this.pLine);
601
+ this.setVP(this.pLine);
602
+ this.setFog(this.pLine, fogK);
603
+ this.drawLines(this.bLine, this.sLine.length / 8, null);
604
+ if (this.dLineA.length)
605
+ this.drawLines(this.bLineD, this.dLineA.length / 8, new Float32Array(this.dLineA));
606
+ gl.useProgram(this.pPt);
607
+ this.setVP(this.pPt);
608
+ this.setFog(this.pPt, fogK);
609
+ const uF = gl.getUniformLocation(this.pPt, "uFocal");
610
+ gl.uniform1f(uF, this.focal * (this.canvas.width / Math.max(1, this.W)));
611
+ this.drawPts(this.bPt, this.sPt.length / 9, null);
612
+ if (this.dPtA.length)
613
+ this.drawPts(this.bPtD, this.dPtA.length / 9, new Float32Array(this.dPtA));
614
+ gl.depthMask(true);
615
+ // the labels — 2D overlay, never occluded (the honest exception, as in 2d)
616
+ this.og.clearRect(0, 0, this.W, this.H);
617
+ this.og.font = "600 11px ui-monospace, Menlo, monospace";
618
+ this.og.textAlign = "center";
619
+ this.og.textBaseline = "middle";
620
+ for (const l of this.labels) {
621
+ const q = this.project(l.p);
622
+ if (!q)
623
+ continue;
624
+ this.og.globalAlpha = clamp(l.alpha, 0, 1);
625
+ this.og.fillStyle = l.color;
626
+ this.og.fillText(l.text, q.x, q.y + l.dy);
627
+ }
628
+ this.og.globalAlpha = 1;
629
+ if (this.vignette > 0) { // v0.12 — the cinematic vignette, on the overlay
630
+ const g = this.og, W = this.W, H = this.H;
631
+ 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);
632
+ grd.addColorStop(0, "rgba(0,0,0,0)");
633
+ grd.addColorStop(1, `rgba(6,4,14,${this.vignette})`);
634
+ g.fillStyle = grd;
635
+ g.fillRect(0, 0, W, H);
636
+ }
637
+ }
638
+ setVP(p) {
639
+ this.gl.uniformMatrix4fv(this.gl.getUniformLocation(p, "uVP"), false, this.vp);
640
+ this.gl.uniform1fv(this.gl.getUniformLocation(p, "uGA"), this.groupAlpha);
641
+ }
642
+ setFog(p, fogK) {
643
+ this.gl.uniform1f(this.gl.getUniformLocation(p, "uFogK"), fogK);
644
+ }
645
+ setLights(p, fogK) {
646
+ const gl = this.gl, L = norm(this.light.dir);
647
+ gl.uniform3f(gl.getUniformLocation(p, "uLdir"), L.x, L.y, L.z);
648
+ gl.uniform1f(gl.getUniformLocation(p, "uAmb"), this.light.ambient);
649
+ gl.uniform1f(gl.getUniformLocation(p, "uDif"), this.light.diffuse);
650
+ gl.uniform1f(gl.getUniformLocation(p, "uSigned"), this.light.signed ? 1 : 0);
651
+ gl.uniform3f(gl.getUniformLocation(p, "uCam"), this.cx.x, this.cx.y, this.cx.z);
652
+ gl.uniform1f(gl.getUniformLocation(p, "uSpec"), this.specular);
653
+ gl.uniform1f(gl.getUniformLocation(p, "uPulse"), this.bloodPulse);
654
+ gl.uniform1f(gl.getUniformLocation(p, "uGrade"), this.grade ? 1 : 0);
655
+ gl.uniform3f(gl.getUniformLocation(p, "uSkyC"), this.ambientSky.x, this.ambientSky.y, this.ambientSky.z);
656
+ gl.uniform3f(gl.getUniformLocation(p, "uGroundC"), this.ambientGround.x, this.ambientGround.y, this.ambientGround.z);
657
+ gl.uniform1f(gl.getUniformLocation(p, "uFilm"), this.filmic ? 1 : 0);
658
+ gl.uniformMatrix4fv(gl.getUniformLocation(p, "uSVP"), false, this.svp);
659
+ gl.uniform1f(gl.getUniformLocation(p, "uShadowOn"), this.shadowsOn && this.shadowTex && this.lights.length ? 1 : 0);
660
+ if (this.shadowTex) {
661
+ gl.activeTexture(gl.TEXTURE0);
662
+ gl.bindTexture(gl.TEXTURE_2D, this.shadowTex);
663
+ gl.uniform1i(gl.getUniformLocation(p, "uShadow"), 0);
664
+ }
665
+ this.setFog(p, fogK);
666
+ const n = Math.min(4, this.lights.length);
667
+ gl.uniform1i(gl.getUniformLocation(p, "uLn"), n);
668
+ const lp = new Float32Array(12), li = new Float32Array(4), lf = new Float32Array(4);
669
+ for (let i = 0; i < n; i++) {
670
+ lp[i * 3] = this.lights[i].p.x;
671
+ lp[i * 3 + 1] = this.lights[i].p.y;
672
+ lp[i * 3 + 2] = this.lights[i].p.z;
673
+ li[i] = this.lights[i].intensity;
674
+ lf[i] = this.lights[i].falloff;
675
+ }
676
+ gl.uniform3fv(gl.getUniformLocation(p, "uLp"), lp);
677
+ gl.uniform1fv(gl.getUniformLocation(p, "uLi"), li);
678
+ gl.uniform1fv(gl.getUniformLocation(p, "uLf"), lf);
679
+ }
680
+ attrib(p, name, size, stride, off) {
681
+ const gl = this.gl, loc = gl.getAttribLocation(p, name);
682
+ if (loc < 0)
683
+ return;
684
+ gl.enableVertexAttribArray(loc);
685
+ gl.vertexAttribPointer(loc, size, gl.FLOAT, false, stride * 4, off * 4);
686
+ }
687
+ depthDraw(buf, verts) {
688
+ if (!verts)
689
+ return;
690
+ const gl = this.gl;
691
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
692
+ const loc = gl.getAttribLocation(this.pDepth, "aPos");
693
+ gl.enableVertexAttribArray(loc);
694
+ gl.vertexAttribPointer(loc, 3, gl.FLOAT, false, 12 * 4, 0);
695
+ gl.drawArrays(gl.TRIANGLES, 0, verts);
696
+ }
697
+ drawFaces(buf, verts, dyn) {
698
+ if (!verts)
699
+ return;
700
+ const gl = this.gl;
701
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
702
+ if (dyn)
703
+ gl.bufferData(gl.ARRAY_BUFFER, dyn, gl.DYNAMIC_DRAW);
704
+ this.attrib(this.pFace, "aPos", 3, 12, 0);
705
+ this.attrib(this.pFace, "aNorm", 3, 12, 3);
706
+ this.attrib(this.pFace, "aCol", 4, 12, 6);
707
+ this.attrib(this.pFace, "aBlood", 1, 12, 10);
708
+ this.attrib(this.pFace, "aGrp", 1, 12, 11);
709
+ gl.drawArrays(gl.TRIANGLES, 0, verts);
710
+ }
711
+ drawLines(buf, verts, dyn) {
712
+ if (!verts)
713
+ return;
714
+ const gl = this.gl;
715
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
716
+ if (dyn)
717
+ gl.bufferData(gl.ARRAY_BUFFER, dyn, gl.DYNAMIC_DRAW);
718
+ this.attrib(this.pLine, "aPos", 3, 8, 0);
719
+ this.attrib(this.pLine, "aCol", 4, 8, 3);
720
+ this.attrib(this.pLine, "aGrp", 1, 8, 7);
721
+ gl.drawArrays(gl.LINES, 0, verts);
722
+ }
723
+ drawPts(buf, verts, dyn) {
724
+ if (!verts)
725
+ return;
726
+ const gl = this.gl;
727
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
728
+ if (dyn)
729
+ gl.bufferData(gl.ARRAY_BUFFER, dyn, gl.DYNAMIC_DRAW);
730
+ this.attrib(this.pPt, "aPos", 3, 9, 0);
731
+ this.attrib(this.pPt, "aSize", 1, 9, 3);
732
+ this.attrib(this.pPt, "aCol", 4, 9, 4);
733
+ this.attrib(this.pPt, "aGrp", 1, 9, 8);
734
+ gl.drawArrays(gl.POINTS, 0, verts);
735
+ }
736
+ start() {
737
+ const frame = (ts) => {
738
+ if (this.disposed)
739
+ return;
740
+ if (!this.t0) {
741
+ this.t0 = ts;
742
+ this.tPrev = ts;
743
+ }
744
+ const t = (ts - this.t0) / 1000;
745
+ const dt = Math.min(0.05, (ts - this.tPrev) / 1000);
746
+ this.tPrev = ts;
747
+ this.renderFrame(t, dt);
748
+ this.raf = !document.hidden ? requestAnimationFrame(frame) : 0;
749
+ };
750
+ this.raf = requestAnimationFrame(frame);
751
+ const onVis = () => {
752
+ if (!document.hidden && !this.raf && !this.disposed) {
753
+ this.tPrev = performance.now();
754
+ this.raf = requestAnimationFrame(frame);
755
+ }
756
+ };
757
+ document.addEventListener("visibilitychange", onVis);
758
+ this.cleanup.push(() => document.removeEventListener("visibilitychange", onVis));
759
+ }
760
+ settle(t) { this.renderFrame(t, 0); }
761
+ dispose() {
762
+ this.disposed = true;
763
+ cancelAnimationFrame(this.raf);
764
+ this.raf = 0;
765
+ for (const c of this.cleanup)
766
+ c();
767
+ this.cleanup = [];
768
+ this.overlay.remove();
769
+ }
770
+ }
771
+ export function createWorld(o) { return new World(o); }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@chromatic-coherence/generative-charts-3d",
3
+ "version": "0.12.0",
4
+ "description": "The second generative-charts engine — the same API as generative-charts-2d, rendered on WebGL: a real depth buffer (occlusion exact, always on, for solids and light) and per-pixel Lambert + point lights. Zero dependencies.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "sideEffects": false,
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.json",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "keywords": [
29
+ "3d",
30
+ "webgl",
31
+ "engine",
32
+ "charts",
33
+ "zero-dependency",
34
+ "graphics"
35
+ ],
36
+ "author": "Chromatic Coherence",
37
+ "license": "SEE LICENSE IN LICENSE",
38
+ "devDependencies": {
39
+ "typescript": "^5"
40
+ }
41
+ }