@chromatic-coherence/generative-engine 1.0.3 → 1.1.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/README.md +40 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/objectives.d.ts +96 -0
- package/dist/objectives.js +174 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,6 +49,46 @@ w.start();
|
|
|
49
49
|
**The ink edition** (`theme: "light"`) still renders any recipe on paper instead of night —
|
|
50
50
|
lightness inverts, strokes blend normally, SSAO becomes pencil shading. Bloom stays dark-only.
|
|
51
51
|
|
|
52
|
+
## Objectives — scenes that know where they're going
|
|
53
|
+
|
|
54
|
+
A scene gets a **goal** and the engine drives motion toward it; a live **movement meter**
|
|
55
|
+
tells you whether the scene is moving or settled — and names what is moving.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { createWorld, attachObjectives } from "@chromatic-coherence/generative-engine";
|
|
59
|
+
|
|
60
|
+
const w = createWorld({ canvas });
|
|
61
|
+
const obj = attachObjectives(w);
|
|
62
|
+
|
|
63
|
+
obj.define("reveal", {
|
|
64
|
+
morph: { group: "terrain", to: 1 }, // blend the terrain to its B shape
|
|
65
|
+
camera: { dist: 420, pitch: 0.62 }, // settle the camera in close
|
|
66
|
+
fade: { group: "veil", to: 0 }, // dissolve the veil
|
|
67
|
+
pace: 3, // seconds to ~95% of the way there
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
obj.onReading(r => {
|
|
71
|
+
// { name, progress, level, floor, moving, settled, driving: ["morph:terrain","camera",...] }
|
|
72
|
+
if (r.settled) console.log(`${r.name}: settled`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// later — the goal itself moves; motion continues from the current state, no snap,
|
|
76
|
+
// and the meter lights up again:
|
|
77
|
+
obj.retarget("reveal", { camera: { dist: 300 } });
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Channels: `transform` (group rigid-body spec) · `morph` · `fade` · `camera` · `custom`
|
|
81
|
+
(an `apply(progress, dt)` escape hatch). `readings()` is the pull form of `onReading`.
|
|
82
|
+
|
|
83
|
+
**The meter's semantics** come from the objective-movement measurement work this engine's
|
|
84
|
+
org ran first as science: movement is a **level sustained above a settled floor**, not a
|
|
85
|
+
discrete event. `driving` names the elevated channels — movement you can name; `settled`
|
|
86
|
+
is the sustained fall to the floor. Options: `attachObjectives(w, { floor, settleAfter })`.
|
|
87
|
+
|
|
88
|
+
The layer rides the frame loop as a `grow()` hook and drives the World through its public
|
|
89
|
+
verbs only — the renderer is untouched, and everything composes with hand-written `grow()`
|
|
90
|
+
animation.
|
|
91
|
+
|
|
52
92
|
## Demo
|
|
53
93
|
|
|
54
94
|
`demo/index.html` — serve the package folder (`python -m http.server`) and open
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { V3, TransformSpec } from "./math.js";
|
|
2
|
+
import type { World } from "./world.js";
|
|
3
|
+
export type ObjectiveCamera = {
|
|
4
|
+
target?: V3;
|
|
5
|
+
dist?: number;
|
|
6
|
+
pitch?: number;
|
|
7
|
+
yaw?: number;
|
|
8
|
+
};
|
|
9
|
+
export type ObjectiveSpec = {
|
|
10
|
+
/** rigid-body goal for a named group — interpolated spec, applied via setTransform */
|
|
11
|
+
transform?: {
|
|
12
|
+
group: string;
|
|
13
|
+
to: TransformSpec;
|
|
14
|
+
from?: TransformSpec;
|
|
15
|
+
};
|
|
16
|
+
/** morph-weight goal for a named group (meshMorph geometry). Default from: 0. */
|
|
17
|
+
morph?: {
|
|
18
|
+
group: string;
|
|
19
|
+
to: number;
|
|
20
|
+
from?: number;
|
|
21
|
+
};
|
|
22
|
+
/** fade goal for a named group. Default from: 1. */
|
|
23
|
+
fade?: {
|
|
24
|
+
group: string;
|
|
25
|
+
to: number;
|
|
26
|
+
from?: number;
|
|
27
|
+
};
|
|
28
|
+
/** camera goal — absent fields keep the camera's current value */
|
|
29
|
+
camera?: ObjectiveCamera;
|
|
30
|
+
/** escape hatch — called every tick with eased progress 0..1 */
|
|
31
|
+
custom?: (progress: number, dt: number) => void;
|
|
32
|
+
/** seconds to ~95 % of the way there. Default 3. */
|
|
33
|
+
pace?: number;
|
|
34
|
+
};
|
|
35
|
+
/** One tick's reading for one objective — the movement meter's voice. */
|
|
36
|
+
export type MovementReading = {
|
|
37
|
+
name: string;
|
|
38
|
+
/** eased approach to the goal, 0..1 */
|
|
39
|
+
progress: number;
|
|
40
|
+
/** movement level this tick (≈ 1 at a fresh goal, decaying toward 0) */
|
|
41
|
+
level: number;
|
|
42
|
+
/** the settled floor the level is read against */
|
|
43
|
+
floor: number;
|
|
44
|
+
/** level is above the floor */
|
|
45
|
+
moving: boolean;
|
|
46
|
+
/** level has stayed at/below the floor for settleAfter seconds */
|
|
47
|
+
settled: boolean;
|
|
48
|
+
/** the channels currently elevated — the movement you can name */
|
|
49
|
+
driving: string[];
|
|
50
|
+
};
|
|
51
|
+
export type ObjectivesOpts = {
|
|
52
|
+
/** the settled floor (movement level units). Default 0.02. */
|
|
53
|
+
floor?: number;
|
|
54
|
+
/** seconds the level must hold at/below the floor to read settled. Default 0.6. */
|
|
55
|
+
settleAfter?: number;
|
|
56
|
+
};
|
|
57
|
+
/** Channel writes, injected — a World in production, plain recorders in tests. */
|
|
58
|
+
export type ChannelAppliers = {
|
|
59
|
+
transform(group: string, spec: TransformSpec): void;
|
|
60
|
+
morph(group: string, weight: number): void;
|
|
61
|
+
fade(group: string, alpha: number): void;
|
|
62
|
+
camera(c: ObjectiveCamera): void;
|
|
63
|
+
};
|
|
64
|
+
export declare class ObjectivesCore {
|
|
65
|
+
private apply;
|
|
66
|
+
private objs;
|
|
67
|
+
private listeners;
|
|
68
|
+
private floor;
|
|
69
|
+
private settleAfter;
|
|
70
|
+
constructor(apply: ChannelAppliers, opts?: ObjectivesOpts);
|
|
71
|
+
/** Define (or wholly replace) a named objective. The drive starts on the next tick. */
|
|
72
|
+
define(name: string, spec: ObjectiveSpec, camNow?: ObjectiveCamera): void;
|
|
73
|
+
/** Move a live objective's goal — progress restarts FROM THE CURRENT STATE (no snap)
|
|
74
|
+
* and the meter lights up again. Channels absent from the patch keep their goal. */
|
|
75
|
+
retarget(name: string, patch: Partial<ObjectiveSpec>, camNow?: ObjectiveCamera): void;
|
|
76
|
+
/** Forget an objective — its channels stay wherever they were last driven to. */
|
|
77
|
+
remove(name: string): void;
|
|
78
|
+
/** Subscribe to per-tick readings (one call per live objective per tick). */
|
|
79
|
+
onReading(cb: (r: MovementReading) => void): () => void;
|
|
80
|
+
/** Pull the latest readings — the same objects the callbacks saw. */
|
|
81
|
+
readings(): MovementReading[];
|
|
82
|
+
/** Advance every objective by dt seconds and apply the driven values.
|
|
83
|
+
* dt <= 0 (settle() stills) re-applies values without advancing the meter. */
|
|
84
|
+
tick(dt: number): void;
|
|
85
|
+
}
|
|
86
|
+
/** The user-facing facade returned by attachObjectives(). */
|
|
87
|
+
export type Objectives = {
|
|
88
|
+
define(name: string, spec: ObjectiveSpec): void;
|
|
89
|
+
retarget(name: string, patch: Partial<ObjectiveSpec>): void;
|
|
90
|
+
remove(name: string): void;
|
|
91
|
+
onReading(cb: (r: MovementReading) => void): () => void;
|
|
92
|
+
readings(): MovementReading[];
|
|
93
|
+
};
|
|
94
|
+
/** Attach the objectives layer to a World. Rides the frame loop as a grow() hook and
|
|
95
|
+
* drives the World through its public verbs — the renderer is untouched. */
|
|
96
|
+
export declare function attachObjectives(world: World, opts?: ObjectivesOpts): Objectives;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// generative-engine — objectives.ts: THE OBJECTIVES LAYER. A scene gets a goal and the
|
|
2
|
+
// engine drives motion toward it; a live movement meter reads whether the scene is
|
|
3
|
+
// moving or settled, and names what is moving.
|
|
4
|
+
//
|
|
5
|
+
// Two layers, deliberately split:
|
|
6
|
+
// ObjectivesCore — pure logic, no GL, no DOM. Channel writes go through injected
|
|
7
|
+
// appliers, so the core runs (and is tested) under bare node.
|
|
8
|
+
// attachObjectives(world) — the binder: wires the core to a World through its PUBLIC
|
|
9
|
+
// verbs only (grow / setTransform / morph / fade / cam). world.ts
|
|
10
|
+
// is untouched by this layer.
|
|
11
|
+
//
|
|
12
|
+
// The meter's lineage (documented, not mystified): it is the regime measure from the
|
|
13
|
+
// objective-movement work (science/walk/station-23) carried into engineering — movement
|
|
14
|
+
// is a LEVEL sustained above a settled FLOOR, not a discrete event. `driving` names the
|
|
15
|
+
// elevated channels — the movement you can name; `settled` is the sustained fall to the
|
|
16
|
+
// floor: the turn you cannot name is the stop.
|
|
17
|
+
import { v3, clamp } from "./math.js";
|
|
18
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
19
|
+
const lerpV3 = (a, b, t) => v3(lerp(a.x, b.x, t), lerp(a.y, b.y, t), lerp(a.z, b.z, t));
|
|
20
|
+
const asV3 = (s, d) => s == null ? v3(d, d, d) : typeof s === "number" ? v3(s, s, s) : s;
|
|
21
|
+
function lerpSpec(a, b, t) {
|
|
22
|
+
return {
|
|
23
|
+
translate: lerpV3(a.translate ?? v3(), b.translate ?? v3(), t),
|
|
24
|
+
rotateX: lerp(a.rotateX ?? 0, b.rotateX ?? 0, t),
|
|
25
|
+
rotateY: lerp(a.rotateY ?? 0, b.rotateY ?? 0, t),
|
|
26
|
+
rotateZ: lerp(a.rotateZ ?? 0, b.rotateZ ?? 0, t),
|
|
27
|
+
scale: lerpV3(asV3(a.scale, 1), asV3(b.scale, 1), t),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export class ObjectivesCore {
|
|
31
|
+
constructor(apply, opts = {}) {
|
|
32
|
+
this.apply = apply;
|
|
33
|
+
this.objs = new Map();
|
|
34
|
+
this.listeners = [];
|
|
35
|
+
this.floor = opts.floor ?? 0.02;
|
|
36
|
+
this.settleAfter = opts.settleAfter ?? 0.6;
|
|
37
|
+
}
|
|
38
|
+
/** Define (or wholly replace) a named objective. The drive starts on the next tick. */
|
|
39
|
+
define(name, spec, camNow) {
|
|
40
|
+
this.objs.set(name, {
|
|
41
|
+
name, spec,
|
|
42
|
+
k: 3 / Math.max(0.05, spec.pace ?? 3),
|
|
43
|
+
progress: 0, level: 1, atFloorFor: 0,
|
|
44
|
+
camFrom: spec.camera ? (camNow ?? {}) : null,
|
|
45
|
+
reading: { name, progress: 0, level: 1, floor: this.floor, moving: true, settled: false, driving: [] },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** Move a live objective's goal — progress restarts FROM THE CURRENT STATE (no snap)
|
|
49
|
+
* and the meter lights up again. Channels absent from the patch keep their goal. */
|
|
50
|
+
retarget(name, patch, camNow) {
|
|
51
|
+
const o = this.objs.get(name);
|
|
52
|
+
if (!o)
|
|
53
|
+
throw new Error(`generative-engine objectives: retarget of unknown objective "${name}"`);
|
|
54
|
+
const s = o.spec, p = o.progress;
|
|
55
|
+
const next = { ...s, ...patch };
|
|
56
|
+
// continuity: each patched channel's new `from` is the value currently applied
|
|
57
|
+
if (patch.transform && s.transform)
|
|
58
|
+
next.transform = { ...patch.transform, from: lerpSpec(s.transform.from ?? {}, s.transform.to, p) };
|
|
59
|
+
if (patch.morph && s.morph)
|
|
60
|
+
next.morph = { ...patch.morph, from: lerp(s.morph.from ?? 0, s.morph.to, p) };
|
|
61
|
+
if (patch.fade && s.fade)
|
|
62
|
+
next.fade = { ...patch.fade, from: lerp(s.fade.from ?? 1, s.fade.to, p) };
|
|
63
|
+
const camFrom = patch.camera ? (camNow ?? o.camFrom ?? {}) : o.camFrom;
|
|
64
|
+
this.objs.set(name, {
|
|
65
|
+
name, spec: next,
|
|
66
|
+
k: 3 / Math.max(0.05, next.pace ?? 3),
|
|
67
|
+
progress: 0, level: 1, atFloorFor: 0,
|
|
68
|
+
camFrom,
|
|
69
|
+
reading: o.reading,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** Forget an objective — its channels stay wherever they were last driven to. */
|
|
73
|
+
remove(name) { this.objs.delete(name); }
|
|
74
|
+
/** Subscribe to per-tick readings (one call per live objective per tick). */
|
|
75
|
+
onReading(cb) {
|
|
76
|
+
this.listeners.push(cb);
|
|
77
|
+
return () => { const i = this.listeners.indexOf(cb); if (i >= 0)
|
|
78
|
+
this.listeners.splice(i, 1); };
|
|
79
|
+
}
|
|
80
|
+
/** Pull the latest readings — the same objects the callbacks saw. */
|
|
81
|
+
readings() {
|
|
82
|
+
return [...this.objs.values()].map(o => o.reading);
|
|
83
|
+
}
|
|
84
|
+
/** Advance every objective by dt seconds and apply the driven values.
|
|
85
|
+
* dt <= 0 (settle() stills) re-applies values without advancing the meter. */
|
|
86
|
+
tick(dt) {
|
|
87
|
+
for (const o of this.objs.values()) {
|
|
88
|
+
if (dt > 0) {
|
|
89
|
+
o.progress += (1 - o.progress) * Math.min(1, dt * o.k);
|
|
90
|
+
// the level is the drive still outstanding — ≈1 at a fresh goal, easing to 0.
|
|
91
|
+
// A retarget resets progress, so the level re-elevates: movement is a REGIME.
|
|
92
|
+
o.level = 1 - o.progress;
|
|
93
|
+
if (o.level <= this.floor)
|
|
94
|
+
o.atFloorFor += dt;
|
|
95
|
+
else
|
|
96
|
+
o.atFloorFor = 0;
|
|
97
|
+
}
|
|
98
|
+
const p = o.progress, s = o.spec;
|
|
99
|
+
const driving = [];
|
|
100
|
+
const moving = o.level > this.floor;
|
|
101
|
+
if (s.transform) {
|
|
102
|
+
this.apply.transform(s.transform.group, lerpSpec(s.transform.from ?? {}, s.transform.to, p));
|
|
103
|
+
if (moving)
|
|
104
|
+
driving.push(`transform:${s.transform.group}`);
|
|
105
|
+
}
|
|
106
|
+
if (s.morph) {
|
|
107
|
+
this.apply.morph(s.morph.group, clamp(lerp(s.morph.from ?? 0, s.morph.to, p), 0, 1));
|
|
108
|
+
if (moving)
|
|
109
|
+
driving.push(`morph:${s.morph.group}`);
|
|
110
|
+
}
|
|
111
|
+
if (s.fade) {
|
|
112
|
+
this.apply.fade(s.fade.group, clamp(lerp(s.fade.from ?? 1, s.fade.to, p), 0, 1));
|
|
113
|
+
if (moving)
|
|
114
|
+
driving.push(`fade:${s.fade.group}`);
|
|
115
|
+
}
|
|
116
|
+
if (s.camera && o.camFrom) {
|
|
117
|
+
const c = s.camera, f = o.camFrom, out = {};
|
|
118
|
+
if (c.target)
|
|
119
|
+
out.target = lerpV3(f.target ?? c.target, c.target, p);
|
|
120
|
+
if (c.dist != null)
|
|
121
|
+
out.dist = lerp(f.dist ?? c.dist, c.dist, p);
|
|
122
|
+
if (c.pitch != null)
|
|
123
|
+
out.pitch = lerp(f.pitch ?? c.pitch, c.pitch, p);
|
|
124
|
+
if (c.yaw != null)
|
|
125
|
+
out.yaw = lerp(f.yaw ?? c.yaw, c.yaw, p);
|
|
126
|
+
this.apply.camera(out);
|
|
127
|
+
if (moving)
|
|
128
|
+
driving.push("camera");
|
|
129
|
+
}
|
|
130
|
+
if (s.custom) {
|
|
131
|
+
s.custom(p, Math.max(0, dt));
|
|
132
|
+
if (moving)
|
|
133
|
+
driving.push("custom");
|
|
134
|
+
}
|
|
135
|
+
o.reading = {
|
|
136
|
+
name: o.name, progress: p, level: o.level, floor: this.floor,
|
|
137
|
+
moving, settled: !moving && o.atFloorFor >= this.settleAfter, driving,
|
|
138
|
+
};
|
|
139
|
+
for (const cb of this.listeners)
|
|
140
|
+
cb(o.reading);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Attach the objectives layer to a World. Rides the frame loop as a grow() hook and
|
|
145
|
+
* drives the World through its public verbs — the renderer is untouched. */
|
|
146
|
+
export function attachObjectives(world, opts = {}) {
|
|
147
|
+
const core = new ObjectivesCore({
|
|
148
|
+
transform: (g, spec) => world.setTransform(g, spec),
|
|
149
|
+
morph: (g, w) => world.morph(g, w),
|
|
150
|
+
fade: (g, a) => world.fade(g, a),
|
|
151
|
+
camera: (c) => {
|
|
152
|
+
if (c.target)
|
|
153
|
+
world.cam.target = c.target;
|
|
154
|
+
if (c.dist != null)
|
|
155
|
+
world.cam.distT = c.dist;
|
|
156
|
+
if (c.pitch != null)
|
|
157
|
+
world.cam.pitch = c.pitch;
|
|
158
|
+
if (c.yaw != null)
|
|
159
|
+
world.cam.yawBase = c.yaw;
|
|
160
|
+
},
|
|
161
|
+
}, opts);
|
|
162
|
+
const camNow = () => ({
|
|
163
|
+
target: world.cam.target, dist: world.cam.distT,
|
|
164
|
+
pitch: world.cam.pitch, yaw: world.cam.yawBase,
|
|
165
|
+
});
|
|
166
|
+
world.grow((_t, dt) => core.tick(dt));
|
|
167
|
+
return {
|
|
168
|
+
define: (name, spec) => core.define(name, spec, camNow()),
|
|
169
|
+
retarget: (name, patch) => core.retarget(name, patch, camNow()),
|
|
170
|
+
remove: (name) => core.remove(name),
|
|
171
|
+
onReading: (cb) => core.onReading(cb),
|
|
172
|
+
readings: () => core.readings(),
|
|
173
|
+
};
|
|
174
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chromatic-coherence/generative-engine",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "THE THIRD ENGINE — a zero-dependency WebGL2 graphics engine grown from generative-charts-3d: HDR + bloom + SSAO post chain, hardware-PCF shadow maps, a geometry stdlib, ribbon strokes, group transforms and morph targets. The charts API rides on top unchanged.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|