@chromatic-coherence/generative-engine 1.7.2 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,6 +48,8 @@ w.start();
48
48
  | **Real water** | `w.water` — the scene renders a second time through the reflected camera (flocks included) under a wave-displaced fresnel surface. Carve a basin, get a lake. |
49
49
  | **Skeletal figures** | `humanoid()` / `quadruped()` / your own rig + `w.figure()` — bones grown from code, bent in the vertex stage, with `walkPose` / `trotPose` procedural cycles. |
50
50
  | **Objectives** | `attachObjectives(w)` — give a scene a goal and it drives itself there; a live movement meter tells you what is moving and when it has settled. |
51
+ | **Collision control** | `mesh(geo, { collider: true })` registers the mesh's bounding sphere; `addCollider(p, r, group?)` places invisible ones. Query with `collide(p, r)` (push-out normal + overlap depth), `collideAll`, and `rayHit(o, dir, maxDist?, r?)` — pass `r` to sweep a moving sphere. Colliders ride their group's `setTransform`. The pure kit (`sphereHit` · `raySphere` · `boundingSphere`) is exported and node-tested. |
52
+ | **Poster frames** | `w.poster(t?)` renders the window's first frame as a still — HTML video's `poster`, for worlds. `capturePoster(opts, build, width, height, t?)` renders it off-screen and returns a PNG data URL with the GL context released: paint the poster at page load, build the live world only when the window is watched. |
51
53
  | **Cinema + flight** | Depth of field, camera motion blur, filmic grade — and `controls: "fly"`: drag to look, scroll to fly. |
52
54
  | **The carried light model** | Everything the chart edition learned rides along: GGX + Fresnel specular, procedural micro-relief, the perfusion term (`blood`, `bloodPulse`), pore noise, subsurface rim, coloured hemispheric ambient, fog — now resolving toward a real background colour the engine owns. |
53
55
 
@@ -164,6 +166,19 @@ The engine's founding frontiers are landed. What comes next, the scenes will ask
164
166
  organisation. Full terms in [LICENSE](./LICENSE); commercial licensing:
165
167
  [hello@chromaticcoherence.ai](mailto:hello@chromaticcoherence.ai).
166
168
 
169
+ ## Versions
170
+
171
+ - **1.9.1** — docs: this version ledger.
172
+ - **1.9.0** — **collision control**: `mesh(geo, { collider: true })` bounding-sphere registration,
173
+ `addCollider` / `removeCollider`, `collide` / `collideAll` (push-out normal + depth),
174
+ `rayHit` (swept-sphere via `r`); colliders ride their group's `setTransform`. The pure kit
175
+ (`sphereHit` · `raySphere` · `boundingSphere`) exported and node-tested.
176
+ - **1.8.0** — **poster frames**: `World.poster(t)` renders the window's first frame as a still;
177
+ `capturePoster(opts, build, width, height, t?)` renders it off-screen → PNG data URL with the
178
+ GL context released.
179
+ - **1.7.x** — the third engine: HDR post chain (bloom, SSAO, filmic, FXAA), hardware-PCF shadows,
180
+ GPU crowds and flight, real water, skeletal figures, objectives.
181
+
167
182
  ---
168
183
 
169
184
  <div align="center">
@@ -0,0 +1,19 @@
1
+ import { type V3 } from "./math.js";
2
+ import type { Mesh } from "./geometry.js";
3
+ export type Sphere = {
4
+ p: V3;
5
+ r: number;
6
+ };
7
+ /** Overlap between two spheres — null when apart; otherwise the contact:
8
+ * `n` points from b to a (the push-out direction for a), `depth` the overlap. */
9
+ export declare function sphereHit(a: Sphere, b: Sphere): {
10
+ n: V3;
11
+ depth: number;
12
+ } | null;
13
+ /** Ray vs sphere: nearest hit distance t ≥ 0 along a UNIT dir, or null.
14
+ * Pass `r` to sweep a moving sphere of that radius instead of a point;
15
+ * a ray starting inside reports the exit hit. */
16
+ export declare function raySphere(o: V3, dir: V3, s: Sphere, r?: number, maxDist?: number): number | null;
17
+ /** The bounding sphere of a mesh — vertex centroid + max distance. Not the
18
+ * minimal sphere, but stable and cheap; grown once at registration. */
19
+ export declare function boundingSphere(m: Mesh): Sphere;
@@ -0,0 +1,58 @@
1
+ // generative-engine — COLLISION CONTROL, the pure kit: bounding spheres grown
2
+ // from geometry, sphere/sphere overlap, and ray / swept-sphere casts. The World
3
+ // rides on top (addCollider / collide / rayHit) applying group transforms;
4
+ // this file stays DOM-free and node-testable, like the rest of the math kit.
5
+ import { v3, sub, dot, len, scale } from "./math.js";
6
+ /** Overlap between two spheres — null when apart; otherwise the contact:
7
+ * `n` points from b to a (the push-out direction for a), `depth` the overlap. */
8
+ export function sphereHit(a, b) {
9
+ const d = sub(a.p, b.p);
10
+ const dist = len(d);
11
+ const depth = a.r + b.r - dist;
12
+ if (depth <= 0)
13
+ return null;
14
+ return { n: dist > 1e-9 ? scale(d, 1 / dist) : v3(0, 1, 0), depth };
15
+ }
16
+ /** Ray vs sphere: nearest hit distance t ≥ 0 along a UNIT dir, or null.
17
+ * Pass `r` to sweep a moving sphere of that radius instead of a point;
18
+ * a ray starting inside reports the exit hit. */
19
+ export function raySphere(o, dir, s, r = 0, maxDist = Infinity) {
20
+ const R = s.r + r;
21
+ const oc = sub(o, s.p);
22
+ const b = dot(oc, dir);
23
+ const c = dot(oc, oc) - R * R;
24
+ const disc = b * b - c; // unit dir → a = 1
25
+ if (disc < 0)
26
+ return null;
27
+ const sq = Math.sqrt(disc);
28
+ let t = -b - sq;
29
+ if (t < 0)
30
+ t = -b + sq;
31
+ if (t < 0 || t > maxDist)
32
+ return null;
33
+ return t;
34
+ }
35
+ /** The bounding sphere of a mesh — vertex centroid + max distance. Not the
36
+ * minimal sphere, but stable and cheap; grown once at registration. */
37
+ export function boundingSphere(m) {
38
+ const n = m.pos.length / 3;
39
+ if (n === 0)
40
+ return { p: v3(), r: 0 };
41
+ let cx = 0, cy = 0, cz = 0;
42
+ for (let i = 0; i < m.pos.length; i += 3) {
43
+ cx += m.pos[i];
44
+ cy += m.pos[i + 1];
45
+ cz += m.pos[i + 2];
46
+ }
47
+ cx /= n;
48
+ cy /= n;
49
+ cz /= n;
50
+ let r2 = 0;
51
+ for (let i = 0; i < m.pos.length; i += 3) {
52
+ const dx = m.pos[i] - cx, dy = m.pos[i + 1] - cy, dz = m.pos[i + 2] - cz;
53
+ const d2 = dx * dx + dy * dy + dz * dz;
54
+ if (d2 > r2)
55
+ r2 = d2;
56
+ }
57
+ return { p: v3(cx, cy, cz), r: Math.sqrt(r2) };
58
+ }
package/dist/index.d.ts CHANGED
@@ -3,3 +3,4 @@ export * from "./geometry.js";
3
3
  export * from "./world.js";
4
4
  export * from "./objectives.js";
5
5
  export * from "./skeletal.js";
6
+ export * from "./collision.js";
package/dist/index.js CHANGED
@@ -6,3 +6,4 @@ export * from "./geometry.js";
6
6
  export * from "./world.js";
7
7
  export * from "./objectives.js";
8
8
  export * from "./skeletal.js";
9
+ export * from "./collision.js";
package/dist/world.d.ts CHANGED
@@ -25,6 +25,29 @@ export type FaceOpts = {
25
25
  gloss?: number;
26
26
  /** per-face perfusion 0..1: blood near the surface (ruddiness, subsurface bleed, pulse) */
27
27
  blood?: number;
28
+ /** COLLISION CONTROL: register this mesh's bounding sphere as a collider.
29
+ * Attached to the mesh's group, so setTransform moves it with the geometry.
30
+ * (Static meshes only — instanced crowds are not auto-registered.) */
31
+ collider?: boolean;
32
+ };
33
+ /** COLLISION CONTROL — a registered sphere collider (world space at rest;
34
+ * its group's transform is applied at query time). */
35
+ export type Collider = {
36
+ id: number;
37
+ p: V3;
38
+ r: number;
39
+ gi: number;
40
+ };
41
+ export type CollisionHit = {
42
+ collider: Collider;
43
+ p: V3;
44
+ n: V3;
45
+ depth: number;
46
+ };
47
+ export type RayHitResult = {
48
+ collider: Collider;
49
+ t: number;
50
+ p: V3;
28
51
  };
29
52
  export type RibbonOpts = {
30
53
  hue: number;
@@ -248,6 +271,23 @@ export declare class World {
248
271
  /** Set a named group's model transform — a mat4 or a {translate, rotateY, scale...} spec.
249
272
  * Geometry in the group moves rigidly with NO CPU re-tessellation. */
250
273
  setTransform(name: string, m: M4 | TransformSpec): void;
274
+ private colliders;
275
+ private colliderSeq;
276
+ /** Register an invisible sphere collider. Attach it to a named group and it
277
+ * rides that group's setTransform exactly like the geometry. Returns an id. */
278
+ addCollider(p: V3, r: number, group?: string): number;
279
+ removeCollider(id: number): void;
280
+ /** A collider as it stands RIGHT NOW — group transform applied to its centre,
281
+ * the matrix's largest axis scale applied to its radius. */
282
+ private colliderNow;
283
+ /** Deepest overlap between a probe sphere and the colliders, or null.
284
+ * `n` is the push-out direction for the probe; `p` the collider's centre. */
285
+ collide(p: V3, r?: number): CollisionHit | null;
286
+ /** Every collider the probe sphere currently overlaps. */
287
+ collideAll(p: V3, r?: number): CollisionHit[];
288
+ /** Nearest ray hit against the colliders (dir need not be unit). Pass `r`
289
+ * to sweep a moving sphere of that radius instead of a point. */
290
+ rayHit(o: V3, dir: V3, maxDist?: number, r?: number): RayHitResult | null;
251
291
  W: number;
252
292
  H: number;
253
293
  private mx;
@@ -359,6 +399,12 @@ export declare class World {
359
399
  resume(): void;
360
400
  /** Render one deterministic frame at time t (no loop) — stills, tests, screenshots. */
361
401
  settle(t: number): void;
402
+ /** The POSTER FRAME — the still a window shows before its animation starts
403
+ * (HTML video's `poster`, for worlds). Renders the recipe once at `t`
404
+ * (default 0 — the scene's own first frame, its initial background) without
405
+ * starting the loop; a later start()/resume() takes over live. Two passes:
406
+ * the first primes sizing and buffers, the second is the frame that stays. */
407
+ poster(t?: number): void;
362
408
  /** Stop the world. `releaseContext: true` also LOSES the GL context immediately —
363
409
  * browsers cap live contexts per page and reclaim them only at GC, so many-canvas
364
410
  * pages must release on teardown. A lost context is permanently dead on its canvas:
@@ -366,3 +412,13 @@ export declare class World {
366
412
  dispose(releaseContext?: boolean): void;
367
413
  }
368
414
  export declare function createWorld(o: WorldOpts): World;
415
+ /** Render a recipe's poster frame OFF-SCREEN and return it as a PNG data URL —
416
+ * the placeholder-image pattern: paint it into the window (an <img> beneath
417
+ * the live canvas) at page load, and build the live world only when the
418
+ * window scrolls into view. The scratch world renders on a hidden staged
419
+ * canvas (sizing reads clientWidth, so it must be in the DOM) and its GL
420
+ * context is released before returning — a page can poster every window it
421
+ * owns without spending its live-context budget. Overlay labels (drawLabel)
422
+ * are not part of a poster. Returns null when WebGL is unavailable — callers
423
+ * keep their CSS fallback. */
424
+ export declare function capturePoster(o: Omit<WorldOpts, "canvas" | "controls">, build: (w: World) => void, width: number, height: number, t?: number): string | null;
package/dist/world.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // WebGL2 renderer: HDR scene target + post chain (bloom, SSAO, composite, FXAA),
4
4
  // hardware-PCF depth-texture shadows, per-group transforms + morph targets in the
5
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, mInvert, normalMat3, } from "./math.js";
6
+ import { v3, add, sub, scale, cross, norm, clamp, hsl, mIdent, mPerspective, mLookAt, mMul, mCompose, mInvert, normalMat3, applyM4, } from "./math.js";
7
+ import { sphereHit, raySphere, boundingSphere } from "./collision.js";
7
8
  import { FACE_VS, FACE_FS, DEPTH_VS, DEPTH_FS, LINE_VS, LINE_FS, PT_VS, PT_FS, RIBBON_VS, RIBBON_FS, FACE_INST_VS, DEPTH_INST_VS, WATER_VS, WATER_FS, FACE_SKIN_VS, DEPTH_SKIN_VS, } from "./shaders.js";
8
9
  import { composePose } from "./skeletal.js";
9
10
  import { PInfo, PostChain } from "./post.js";
@@ -33,6 +34,64 @@ export class World {
33
34
  this.groupMat.set(mat, i * 16);
34
35
  this.groupNM.set(normalMat3(mat), i * 9);
35
36
  }
37
+ /** Register an invisible sphere collider. Attach it to a named group and it
38
+ * rides that group's setTransform exactly like the geometry. Returns an id. */
39
+ addCollider(p, r, group) {
40
+ const id = this.colliderSeq++;
41
+ this.colliders.push({ id, p: v3(p.x, p.y, p.z), r, gi: this.idxFor(group) });
42
+ return id;
43
+ }
44
+ removeCollider(id) {
45
+ const i = this.colliders.findIndex((c) => c.id === id);
46
+ if (i >= 0)
47
+ this.colliders.splice(i, 1);
48
+ }
49
+ /** A collider as it stands RIGHT NOW — group transform applied to its centre,
50
+ * the matrix's largest axis scale applied to its radius. */
51
+ colliderNow(c) {
52
+ const m = this.groupMat.subarray(c.gi * 16, c.gi * 16 + 16);
53
+ const s = Math.max(Math.hypot(m[0], m[1], m[2]), Math.hypot(m[4], m[5], m[6]), Math.hypot(m[8], m[9], m[10]));
54
+ return { p: applyM4(m, c.p), r: c.r * s };
55
+ }
56
+ /** Deepest overlap between a probe sphere and the colliders, or null.
57
+ * `n` is the push-out direction for the probe; `p` the collider's centre. */
58
+ collide(p, r = 0) {
59
+ let best = null;
60
+ for (const c of this.colliders) {
61
+ const s = this.colliderNow(c);
62
+ const h = sphereHit({ p, r }, s);
63
+ if (h && (!best || h.depth > best.depth)) {
64
+ best = { collider: c, p: s.p, n: h.n, depth: h.depth };
65
+ }
66
+ }
67
+ return best;
68
+ }
69
+ /** Every collider the probe sphere currently overlaps. */
70
+ collideAll(p, r = 0) {
71
+ const out = [];
72
+ for (const c of this.colliders) {
73
+ const s = this.colliderNow(c);
74
+ const h = sphereHit({ p, r }, s);
75
+ if (h)
76
+ out.push({ collider: c, p: s.p, n: h.n, depth: h.depth });
77
+ }
78
+ return out;
79
+ }
80
+ /** Nearest ray hit against the colliders (dir need not be unit). Pass `r`
81
+ * to sweep a moving sphere of that radius instead of a point. */
82
+ rayHit(o, dir, maxDist = Infinity, r = 0) {
83
+ const d = norm(dir);
84
+ let bestT = maxDist;
85
+ let bestC = null;
86
+ for (const c of this.colliders) {
87
+ const t = raySphere(o, d, this.colliderNow(c), r, bestT);
88
+ if (t != null && t <= bestT) {
89
+ bestT = t;
90
+ bestC = c;
91
+ }
92
+ }
93
+ return bestC ? { collider: bestC, t: bestT, p: add(o, scale(d, bestT)) } : null;
94
+ }
36
95
  constructor(o) {
37
96
  this.flyMode = false;
38
97
  this.pitchMin = 0.18;
@@ -129,6 +188,9 @@ export class World {
129
188
  this.groupMorphT = new Float32Array(8);
130
189
  this.groupMat = new Float32Array(8 * 16);
131
190
  this.groupNM = new Float32Array(8 * 9);
191
+ // ── COLLISION CONTROL — sphere colliders that ride their group's transform ──
192
+ this.colliders = [];
193
+ this.colliderSeq = 1;
132
194
  this.W = 0;
133
195
  this.H = 0;
134
196
  this.mx = 0;
@@ -374,6 +436,10 @@ export class World {
374
436
  }
375
437
  /** Upload a whole Mesh (geometry.ts builders) as static faces in one call. */
376
438
  mesh(geo, o) {
439
+ if (o.collider) { // collision control: the mesh's bounding
440
+ const bs = boundingSphere(geo); // sphere rides the same group transform
441
+ this.addCollider(bs.p, bs.r, o.group);
442
+ }
377
443
  const P = geo.pos, N = geo.nrm;
378
444
  for (let i = 0; i < P.length; i += 9) {
379
445
  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]));
@@ -1126,6 +1192,12 @@ export class World {
1126
1192
  }
1127
1193
  /** Render one deterministic frame at time t (no loop) — stills, tests, screenshots. */
1128
1194
  settle(t) { this.renderFrame(t, 0); }
1195
+ /** The POSTER FRAME — the still a window shows before its animation starts
1196
+ * (HTML video's `poster`, for worlds). Renders the recipe once at `t`
1197
+ * (default 0 — the scene's own first frame, its initial background) without
1198
+ * starting the loop; a later start()/resume() takes over live. Two passes:
1199
+ * the first primes sizing and buffers, the second is the frame that stays. */
1200
+ poster(t = 0) { this.settle(Math.min(0.02, t)); this.settle(t); }
1129
1201
  /** Stop the world. `releaseContext: true` also LOSES the GL context immediately —
1130
1202
  * browsers cap live contexts per page and reclaim them only at GC, so many-canvas
1131
1203
  * pages must release on teardown. A lost context is permanently dead on its canvas:
@@ -1160,3 +1232,35 @@ export class World {
1160
1232
  }
1161
1233
  }
1162
1234
  export function createWorld(o) { return new World(o); }
1235
+ /** Render a recipe's poster frame OFF-SCREEN and return it as a PNG data URL —
1236
+ * the placeholder-image pattern: paint it into the window (an <img> beneath
1237
+ * the live canvas) at page load, and build the live world only when the
1238
+ * window scrolls into view. The scratch world renders on a hidden staged
1239
+ * canvas (sizing reads clientWidth, so it must be in the DOM) and its GL
1240
+ * context is released before returning — a page can poster every window it
1241
+ * owns without spending its live-context budget. Overlay labels (drawLabel)
1242
+ * are not part of a poster. Returns null when WebGL is unavailable — callers
1243
+ * keep their CSS fallback. */
1244
+ export function capturePoster(o, build, width, height, t = 0) {
1245
+ const stage = document.createElement("div");
1246
+ stage.style.cssText = "position:fixed;left:-99999px;top:0;visibility:hidden;pointer-events:none;"
1247
+ + `width:${Math.max(2, Math.round(width))}px;height:${Math.max(2, Math.round(height))}px;`;
1248
+ const canvas = document.createElement("canvas");
1249
+ canvas.style.cssText = "width:100%;height:100%;display:block;";
1250
+ stage.appendChild(canvas);
1251
+ document.body.appendChild(stage);
1252
+ let w = null;
1253
+ try {
1254
+ w = createWorld({ ...o, canvas, controls: false, maxDpr: 1 });
1255
+ build(w);
1256
+ w.poster(t);
1257
+ return canvas.toDataURL("image/png"); // same task as the render — buffer intact
1258
+ }
1259
+ catch {
1260
+ return null;
1261
+ }
1262
+ finally {
1263
+ w?.dispose(true);
1264
+ stage.remove();
1265
+ }
1266
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chromatic-coherence/generative-engine",
3
- "version": "1.7.2",
3
+ "version": "1.9.1",
4
4
  "description": "A zero-dependency WebGL2 graphics engine where everything is grown from code — no models, no textures, no asset pipeline. Real light and shadow with a cinematic HDR finish (bloom, SSAO, filmic grade, depth of field, motion blur), a geometry stdlib, GPU crowds (instanced wind, ten-thousand-bird flocks), real water with planar reflections, skeletal figures with procedural walk cycles, free-flight controls, and scenes that drive themselves toward objectives. The Generative Charts API rides on top unchanged.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",