@lovo/matter 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { Color } from 'three';
2
2
  import { WebGPURenderer, Node } from 'three/webgpu';
3
- import { ShaderNodeObject } from 'three/tsl';
3
+ import { ShaderNodeObject, uniform } from 'three/tsl';
4
+
5
+ /** The output color gamut the renderer encodes its framebuffer for. */
6
+ type OutputGamut = 'srgb' | 'p3';
4
7
 
5
8
  type GpuBackend = 'webgpu' | 'webgl2';
6
9
  interface CreateRendererOptions {
@@ -14,6 +17,8 @@ interface CreateRendererOptions {
14
17
  clearAlpha?: number;
15
18
  /** Cap on devicePixelRatio. Default: 2. Pass Infinity to disable. */
16
19
  maxDPR?: number;
20
+ /** Output color gamut the framebuffer is encoded for. Default: 'srgb'. */
21
+ gamut?: OutputGamut;
17
22
  }
18
23
  interface GpuRenderer {
19
24
  /** The underlying Three.js WebGPURenderer (which may be running on a WebGL2 backend). */
@@ -34,7 +39,7 @@ interface GpuRenderer {
34
39
  */
35
40
  declare function createRenderer(canvas: HTMLCanvasElement, opts?: CreateRendererOptions): Promise<GpuRenderer>;
36
41
 
37
- type Vec2 = readonly [number, number];
42
+ type Vector2 = readonly [number, number];
38
43
  interface CursorInputOptions {
39
44
  /**
40
45
  * Smoothing factor: 0 = no smoothing (snap to target instantly).
@@ -46,7 +51,7 @@ interface CursorInputOptions {
46
51
  */
47
52
  smoothing?: number;
48
53
  /** Starting position. Default: [0.5, 0.5] (center). */
49
- initial?: Vec2;
54
+ initial?: Vector2;
50
55
  /** Listen on this target. Default: window. */
51
56
  target?: EventTarget;
52
57
  /**
@@ -69,7 +74,7 @@ interface CursorInputOptions {
69
74
  };
70
75
  };
71
76
  }
72
- type ChangeListener = (value: Vec2) => void;
77
+ type ChangeListener = (value: Vector2) => void;
73
78
  /**
74
79
  * Smoothed pointer tracker emitting a normalized (0..1) Vec2 position.
75
80
  * Implements the AnimatableSignal protocol (`get()` + `on('change', cb)`)
@@ -87,9 +92,9 @@ declare class CursorInput {
87
92
  private disposed;
88
93
  constructor(opts?: CursorInputOptions);
89
94
  /** Current smoothed position. Implements AnimatableSignal protocol. */
90
- get(): Vec2;
95
+ get(): Vector2;
91
96
  /** Subscribe to change events. Returns an unsubscribe function. */
92
- on(_event: 'change', cb: ChangeListener): () => void;
97
+ on(_eventType: 'change', changeListener: ChangeListener): () => void;
93
98
  /**
94
99
  * Advance the smoothing one tick. Called by the host scheduler; not
95
100
  * typically called directly except in tests.
@@ -99,6 +104,16 @@ declare class CursorInput {
99
104
  dispose(): void;
100
105
  }
101
106
 
107
+ /** Interpolation space for blending colors. Always converts via linear-sRGB. */
108
+ type ColorSpace = 'linear' | 'oklab' | 'oklch' | 'lch' | 'hsl' | 'hsv';
109
+ /**
110
+ * Which way around the hue wheel cylindrical spaces (oklch/lch/hsl/hsv)
111
+ * interpolate — the CSS Color 4 `hue-interpolation-method` keywords.
112
+ * `shorter`/`longer` pick the arc by length; `increasing`/`decreasing` pick it
113
+ * by direction (and keep multi-stop ramps marching one way around the wheel).
114
+ */
115
+ type HueInterpolation = 'shorter' | 'longer' | 'increasing' | 'decreasing';
116
+
102
117
  /**
103
118
  * Canonical TSL-node *input* shape used throughout `@lovo/matter`.
104
119
  *
@@ -112,7 +127,7 @@ declare class CursorInput {
112
127
  */
113
128
  type TSLNode = Node | ShaderNodeObject<Node>;
114
129
  interface ColorRampStop {
115
- /** Color expressed as a TSL node (typically `vec3(r,g,b)`). */
130
+ /** Color expressed as a TSL node (typically `vec3(r,g,b)`), in linear-sRGB. */
116
131
  color: TSLNode;
117
132
  /** Position 0..1 along the ramp. */
118
133
  position: number;
@@ -121,9 +136,50 @@ interface ColorRampStop {
121
136
  * Multi-stop color interpolation. Given a t in [0..1] and N color stops at
122
137
  * fixed positions, returns the smoothly-interpolated color.
123
138
  *
139
+ * `colorSpace` controls the interpolation space (default `'linear'` — a plain
140
+ * per-channel mix that preserves the input values). Stops are converted into
141
+ * the space up front, the nested-mix chain runs IN that space, and the result
142
+ * is converted back to linear-sRGB once at the end.
143
+ *
144
+ * `hueInterpolation` chooses which way around the wheel cylindrical spaces
145
+ * travel (default `'shorter'`); it's inert for rectangular spaces (linear/oklab).
146
+ *
124
147
  * Falls back to the first/last stop's color outside the bracketing positions.
125
148
  */
126
- declare function colorRamp(t: TSLNode, stops: ColorRampStop[]): ShaderNodeObject<Node>;
149
+ declare function colorRamp(t: TSLNode, stops: ColorRampStop[], colorSpace?: ColorSpace, hueInterpolation?: HueInterpolation): ShaderNodeObject<Node>;
150
+
151
+ /**
152
+ * Blend two linear-sRGB colors in `colorSpace`: convert both endpoints into the
153
+ * space, interpolate, convert back to linear-sRGB. `hueInterpolation` chooses
154
+ * the hue-wheel direction for cylindrical spaces (default `'shorter'`; inert for
155
+ * rectangular spaces). The result is NOT clamped — extended (out-of-sRGB) values
156
+ * are preserved so a wide-gamut (P3) output can display them; an sRGB output
157
+ * clamps per-channel at the framebuffer, identical to the prior behavior.
158
+ */
159
+ declare function mixColor(colorA: TSLNode, colorB: TSLNode, t: TSLNode, colorSpace?: ColorSpace, hueInterpolation?: HueInterpolation): ShaderNodeObject<Node>;
160
+
161
+ /**
162
+ * OKLab (L, a, b) -> extended linear-sRGB. CPU mirror of the TSL `oklabToLinear`
163
+ * in `oklab.ts` (same M2^-1 / cube / M1^-1 matrices). The result is NOT clamped:
164
+ * colors outside sRGB return channels below 0 or above 1, which a wide-gamut
165
+ * output can render and an sRGB output clamps at the framebuffer.
166
+ */
167
+ declare function oklabToLinearSrgb(lightness: number, greenRed: number, blueYellow: number): [number, number, number];
168
+ /** OKLch (L, C, h-in-degrees) -> extended linear-sRGB. */
169
+ declare function oklchToLinearSrgb(lightness: number, chroma: number, hueDegrees: number): [number, number, number];
170
+ /**
171
+ * Parse a color string to **extended** linear-sRGB. Accepts `#rrggbb`,
172
+ * `oklab(L a b)`, and `oklch(L C H)` (CSS Color 4 syntax: L/C may be percentages,
173
+ * H may carry a `deg` suffix, an optional `/ alpha` is parsed and dropped).
174
+ * Throws on any other syntax.
175
+ */
176
+ declare function parseColorString(input: string): [number, number, number];
177
+
178
+ /**
179
+ * sRGB-encoded channel in [0,1] -> linear-sRGB. Standard sRGB EOTF.
180
+ * Mirrors three's `convertSRGBToLinear` (e.g. 0.5 -> 0.21404114).
181
+ */
182
+ declare function srgbChannelToLinear(channel: number): number;
127
183
 
128
184
  /**
129
185
  * 2D simplex noise sampled at a point. Returns a scalar TSL node in
@@ -138,9 +194,9 @@ declare function colorRamp(t: TSLNode, stops: ColorRampStop[]): ShaderNodeObject
138
194
  * Returns `ShaderNodeObject<Node>` (chainable) rather than the broader
139
195
  * `TSLNode` union, so callers can `.add(...)`/`.mul(...)` without casting.
140
196
  */
141
- declare function noise(p: TSLNode): ShaderNodeObject<Node>;
197
+ declare function simplexNoise(p: TSLNode): ShaderNodeObject<Node>;
142
198
 
143
- interface FBMOptions {
199
+ interface FractalNoiseOptions {
144
200
  /** Number of octaves to sum. JS-side number — fixed at TSL build time, not a uniform. Default: 4. */
145
201
  octaves?: number;
146
202
  /** Per-octave frequency multiplier. JS-side number. Default: 2. */
@@ -171,7 +227,7 @@ interface FBMOptions {
171
227
  * @returns scalar TSL node, normalized to roughly [-1..1] regardless of
172
228
  * octave count thanks to the amplitude-sum division at the end.
173
229
  */
174
- declare function fbm(p: TSLNode, opts?: FBMOptions): ShaderNodeObject<Node>;
230
+ declare function fractalNoise(p: TSLNode, opts?: FractalNoiseOptions): ShaderNodeObject<Node>;
175
231
 
176
232
  /**
177
233
  * 2D voronoi (Worley) noise — distance to the nearest jittered cell point,
@@ -209,7 +265,7 @@ declare function quantize(t: ShaderNodeObject<Node>, steps: number): ShaderNodeO
209
265
  * @param p — Vec2 TSL node (typically a UV-space offset from the center).
210
266
  * @param radius — JS-side scalar OR a scalar TSL node.
211
267
  */
212
- declare function sdfCircle(p: TSLNode, radius: TSLNode | number): ShaderNodeObject<Node>;
268
+ declare function signedDistanceFieldCircle(p: TSLNode, radius: TSLNode | number): ShaderNodeObject<Node>;
213
269
 
214
270
  /**
215
271
  * Naive vector addition: returns `p + by`.
@@ -256,40 +312,26 @@ interface CursorRippleOptions {
256
312
  */
257
313
  declare function cursorRipple(p: TSLNode, center: TSLNode, opts?: CursorRippleOptions): ShaderNodeObject<Node>;
258
314
 
259
- declare const time: ShaderNodeObject<Node>;
315
+ declare const elapsedTime: ShaderNodeObject<Node>;
316
+
317
+ type TSLScalar = TSLNode | number;
318
+ declare function filmGrain(intensity: TSLScalar, timeOffset?: TSLScalar): ShaderNodeObject<Node>;
260
319
 
261
320
  /**
262
- * Hash-based film grain chaotic, uncorrelated per-pixel noise sampled
263
- * from `uvNode`. The output is *centered* around zero so it acts as a
264
- * brightness-preserving texture overlay (half the pixels brighten by up
265
- * to `intensity`, half darken, mean unchanged). ADD the result to a color.
266
- *
267
- * filmGrain(uv, k) static grain
268
- * filmGrain(uv, k, time) → twinkling grain. Pass a quantized time node
269
- * (e.g. `time.mul(speed).mul(60).floor()`) so
270
- * the grain re-randomizes at a controllable
271
- * "shutter rate" instead of every frame.
272
- *
273
- * Recipe:
274
- *
275
- * base = vec2(uv·c1, uv·c2) + timeOffset
276
- * hash = fract(sin(base) * 43758.5453)
277
- * out = (length(hash) - 0.765) * intensity
278
- *
279
- * `c1 = (2127.1, 81.17)` and `c2 = (1269.5, 283.37)` are arbitrary
280
- * near-prime constants that produce visually-uncorrelated noise. `0.765`
281
- * is the empirical mean of `length(vec2(u, v))` for uniform u, v ∈ [0, 1),
282
- * computed once so we don't have to subtract it at runtime per pixel.
283
- *
284
- * For a film-stock look (darkens as grain rises — silver-emulsion
285
- * physics) subtract the result from the color instead of adding.
286
- *
287
- * @param uvNode vec2 TSL node, typically `uv()`.
288
- * @param intensity number or TSL node in [0, 1]; scales the grain.
289
- * @param timeOffset optional number or TSL node added to each sample
290
- * before hashing. `0` (default) → static grain.
321
+ * Add sub-LSB dither to break up 8-bit quantization banding (most visible on
322
+ * wide-gamut/P3 output, where the same 256 levels span a wider gamut).
323
+ *
324
+ * `coord` is a per-pixel coordinate (pass `uv()`); `amount` is the noise
325
+ * magnitude in the color's units (default ~1/255, roughly one 8-bit step). Uses a
326
+ * triangular PDF (difference of two hashes) for flatter, less "gritty" noise than
327
+ * uniform white noise.
328
+ *
329
+ * SPIKE NOTE: applied here in linear-sRGB working space (before the renderer's
330
+ * output transfer), so the effective dither is uneven across tones (over-dithers
331
+ * shadows, under-dithers highlights). The correct home is a final output pass
332
+ * after color-space conversion; this is good enough to evaluate the banding fix.
291
333
  */
292
- declare function filmGrain(uvNode: ShaderNodeObject<Node>, intensity: ShaderNodeObject<Node> | number, timeOffset?: ShaderNodeObject<Node> | number): ShaderNodeObject<Node>;
334
+ declare function dither(color: TSLNode, coord: TSLNode, amount?: number): ShaderNodeObject<Node>;
293
335
 
294
336
  type ReducedMotionPolicy = 'auto' | 'off' | 'slow' | 'paused';
295
337
  /**
@@ -325,7 +367,7 @@ declare function createReducedMotionWatcher(): ReducedMotionWatcher;
325
367
  * imperatively when policy changes is safe — TSL re-reads the uniform every
326
368
  * frame.
327
369
  */
328
- declare function getReducedMotionTimeScale(): ShaderNodeObject<Node>;
370
+ declare function getReducedMotionTimeScale(): ReturnType<typeof uniform<number>>;
329
371
 
330
372
  interface VisibilityWatcher {
331
373
  isVisible(): boolean;
@@ -358,28 +400,23 @@ interface IntersectionWatcher {
358
400
  declare function createIntersectionWatcher(canvas: HTMLCanvasElement): IntersectionWatcher;
359
401
 
360
402
  interface SchedulerTick {
361
- /** Seconds since the previous tick. 0 on the first call. */
362
403
  delta: number;
363
- /** Total seconds since the scheduler started its current run. */
364
404
  elapsed: number;
365
- /** The raw `performance.now()` timestamp the rAF callback received. */
366
405
  now: number;
367
406
  }
368
407
  type SchedulerClient = (tick: SchedulerTick) => void;
369
- /**
370
- * Batches `requestAnimationFrame` calls across all clients registered with
371
- * a single scheduler. One scheduler is created per <ShaderScene>; clients
372
- * are typically a Three.js renderer's render call.
373
- */
374
408
  declare class FrameScheduler {
375
409
  private readonly clients;
376
410
  private rafId;
377
411
  private running;
378
412
  private paused;
379
- private idle;
380
413
  private flushPending;
381
414
  private startedAt;
382
415
  private lastTickAt;
416
+ private idleVotes;
417
+ private animatedVotes;
418
+ /** True when all participating components prefer idle and none need animation. */
419
+ get idle(): boolean;
383
420
  /** Activate the scheduler. The rAF loop starts on the first client added. */
384
421
  start(): void;
385
422
  /** Halt the rAF loop entirely. Use dispose() for permanent teardown. */
@@ -395,16 +432,25 @@ declare class FrameScheduler {
395
432
  /** Permanent teardown: stop the loop and drop all clients. */
396
433
  dispose(): void;
397
434
  /**
398
- * Mark the scheduler idle. The next tick still fires (a final flush so
399
- * uniform changes that triggered the idle state are rendered), then the
400
- * rAF loop halts. Use `requestRender()` or `setIdle(false)` to wake.
435
+ * Cast a vote on whether the scheduler should be idle.
436
+ *
437
+ * `setIdle(true)` increments the idle-vote count; the returned cleanup
438
+ * decrements it. `setIdle(false)` increments the animated-vote count;
439
+ * its cleanup decrements that. The scheduler halts (after one flush tick)
440
+ * only when `idleVotes > 0 && animatedVotes === 0`.
441
+ *
442
+ * Callers are responsible for calling the returned cleanup on unmount.
443
+ * Use `requestRender()` or cast a `setIdle(false)` vote to wake the loop
444
+ * without permanently registering an animated preference.
401
445
  */
402
- setIdle(idle: boolean): void;
446
+ setIdle(idle: boolean): () => void;
403
447
  /** Force a single tick while idle. Useful for prop-change invalidation. */
404
448
  requestRender(): void;
449
+ private onBecameIdle;
450
+ private onBecameAnimated;
405
451
  private maybeQueue;
406
452
  private cancel;
407
453
  private readonly frame;
408
454
  }
409
455
 
410
- export { type ColorRampStop, type CreateRendererOptions, CursorInput, type CursorInputOptions, type CursorRippleOptions, type FBMOptions, FrameScheduler, type GpuBackend, type GpuRenderer, type IntersectionWatcher, type ReducedMotionPolicy, type ReducedMotionWatcher, type SchedulerClient, type SchedulerTick, type TSLNode, type Vec2, type VisibilityWatcher, colorRamp, createIntersectionWatcher, createReducedMotionWatcher, createRenderer, createVisibilityWatcher, cursorRipple, displace, fbm, filmGrain, getReducedMotionPolicy, getReducedMotionTimeScale, noise, quantize, sdfCircle, setReducedMotionPolicy, time, voronoi };
456
+ export { type ColorRampStop, type ColorSpace, type CreateRendererOptions, CursorInput, type CursorInputOptions, type CursorRippleOptions, type FractalNoiseOptions, FrameScheduler, type GpuBackend, type GpuRenderer, type HueInterpolation, type IntersectionWatcher, type OutputGamut, type ReducedMotionPolicy, type ReducedMotionWatcher, type SchedulerClient, type SchedulerTick, type TSLNode, type Vector2, type VisibilityWatcher, colorRamp, createIntersectionWatcher, createReducedMotionWatcher, createRenderer, createVisibilityWatcher, cursorRipple, displace, dither, elapsedTime, filmGrain, fractalNoise, getReducedMotionPolicy, getReducedMotionTimeScale, mixColor, oklabToLinearSrgb, oklchToLinearSrgb, parseColorString, quantize, setReducedMotionPolicy, signedDistanceFieldCircle, simplexNoise, srgbChannelToLinear, voronoi };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { Color } from 'three';
2
2
  import { WebGPURenderer, Node } from 'three/webgpu';
3
- import { ShaderNodeObject } from 'three/tsl';
3
+ import { ShaderNodeObject, uniform } from 'three/tsl';
4
+
5
+ /** The output color gamut the renderer encodes its framebuffer for. */
6
+ type OutputGamut = 'srgb' | 'p3';
4
7
 
5
8
  type GpuBackend = 'webgpu' | 'webgl2';
6
9
  interface CreateRendererOptions {
@@ -14,6 +17,8 @@ interface CreateRendererOptions {
14
17
  clearAlpha?: number;
15
18
  /** Cap on devicePixelRatio. Default: 2. Pass Infinity to disable. */
16
19
  maxDPR?: number;
20
+ /** Output color gamut the framebuffer is encoded for. Default: 'srgb'. */
21
+ gamut?: OutputGamut;
17
22
  }
18
23
  interface GpuRenderer {
19
24
  /** The underlying Three.js WebGPURenderer (which may be running on a WebGL2 backend). */
@@ -34,7 +39,7 @@ interface GpuRenderer {
34
39
  */
35
40
  declare function createRenderer(canvas: HTMLCanvasElement, opts?: CreateRendererOptions): Promise<GpuRenderer>;
36
41
 
37
- type Vec2 = readonly [number, number];
42
+ type Vector2 = readonly [number, number];
38
43
  interface CursorInputOptions {
39
44
  /**
40
45
  * Smoothing factor: 0 = no smoothing (snap to target instantly).
@@ -46,7 +51,7 @@ interface CursorInputOptions {
46
51
  */
47
52
  smoothing?: number;
48
53
  /** Starting position. Default: [0.5, 0.5] (center). */
49
- initial?: Vec2;
54
+ initial?: Vector2;
50
55
  /** Listen on this target. Default: window. */
51
56
  target?: EventTarget;
52
57
  /**
@@ -69,7 +74,7 @@ interface CursorInputOptions {
69
74
  };
70
75
  };
71
76
  }
72
- type ChangeListener = (value: Vec2) => void;
77
+ type ChangeListener = (value: Vector2) => void;
73
78
  /**
74
79
  * Smoothed pointer tracker emitting a normalized (0..1) Vec2 position.
75
80
  * Implements the AnimatableSignal protocol (`get()` + `on('change', cb)`)
@@ -87,9 +92,9 @@ declare class CursorInput {
87
92
  private disposed;
88
93
  constructor(opts?: CursorInputOptions);
89
94
  /** Current smoothed position. Implements AnimatableSignal protocol. */
90
- get(): Vec2;
95
+ get(): Vector2;
91
96
  /** Subscribe to change events. Returns an unsubscribe function. */
92
- on(_event: 'change', cb: ChangeListener): () => void;
97
+ on(_eventType: 'change', changeListener: ChangeListener): () => void;
93
98
  /**
94
99
  * Advance the smoothing one tick. Called by the host scheduler; not
95
100
  * typically called directly except in tests.
@@ -99,6 +104,16 @@ declare class CursorInput {
99
104
  dispose(): void;
100
105
  }
101
106
 
107
+ /** Interpolation space for blending colors. Always converts via linear-sRGB. */
108
+ type ColorSpace = 'linear' | 'oklab' | 'oklch' | 'lch' | 'hsl' | 'hsv';
109
+ /**
110
+ * Which way around the hue wheel cylindrical spaces (oklch/lch/hsl/hsv)
111
+ * interpolate — the CSS Color 4 `hue-interpolation-method` keywords.
112
+ * `shorter`/`longer` pick the arc by length; `increasing`/`decreasing` pick it
113
+ * by direction (and keep multi-stop ramps marching one way around the wheel).
114
+ */
115
+ type HueInterpolation = 'shorter' | 'longer' | 'increasing' | 'decreasing';
116
+
102
117
  /**
103
118
  * Canonical TSL-node *input* shape used throughout `@lovo/matter`.
104
119
  *
@@ -112,7 +127,7 @@ declare class CursorInput {
112
127
  */
113
128
  type TSLNode = Node | ShaderNodeObject<Node>;
114
129
  interface ColorRampStop {
115
- /** Color expressed as a TSL node (typically `vec3(r,g,b)`). */
130
+ /** Color expressed as a TSL node (typically `vec3(r,g,b)`), in linear-sRGB. */
116
131
  color: TSLNode;
117
132
  /** Position 0..1 along the ramp. */
118
133
  position: number;
@@ -121,9 +136,50 @@ interface ColorRampStop {
121
136
  * Multi-stop color interpolation. Given a t in [0..1] and N color stops at
122
137
  * fixed positions, returns the smoothly-interpolated color.
123
138
  *
139
+ * `colorSpace` controls the interpolation space (default `'linear'` — a plain
140
+ * per-channel mix that preserves the input values). Stops are converted into
141
+ * the space up front, the nested-mix chain runs IN that space, and the result
142
+ * is converted back to linear-sRGB once at the end.
143
+ *
144
+ * `hueInterpolation` chooses which way around the wheel cylindrical spaces
145
+ * travel (default `'shorter'`); it's inert for rectangular spaces (linear/oklab).
146
+ *
124
147
  * Falls back to the first/last stop's color outside the bracketing positions.
125
148
  */
126
- declare function colorRamp(t: TSLNode, stops: ColorRampStop[]): ShaderNodeObject<Node>;
149
+ declare function colorRamp(t: TSLNode, stops: ColorRampStop[], colorSpace?: ColorSpace, hueInterpolation?: HueInterpolation): ShaderNodeObject<Node>;
150
+
151
+ /**
152
+ * Blend two linear-sRGB colors in `colorSpace`: convert both endpoints into the
153
+ * space, interpolate, convert back to linear-sRGB. `hueInterpolation` chooses
154
+ * the hue-wheel direction for cylindrical spaces (default `'shorter'`; inert for
155
+ * rectangular spaces). The result is NOT clamped — extended (out-of-sRGB) values
156
+ * are preserved so a wide-gamut (P3) output can display them; an sRGB output
157
+ * clamps per-channel at the framebuffer, identical to the prior behavior.
158
+ */
159
+ declare function mixColor(colorA: TSLNode, colorB: TSLNode, t: TSLNode, colorSpace?: ColorSpace, hueInterpolation?: HueInterpolation): ShaderNodeObject<Node>;
160
+
161
+ /**
162
+ * OKLab (L, a, b) -> extended linear-sRGB. CPU mirror of the TSL `oklabToLinear`
163
+ * in `oklab.ts` (same M2^-1 / cube / M1^-1 matrices). The result is NOT clamped:
164
+ * colors outside sRGB return channels below 0 or above 1, which a wide-gamut
165
+ * output can render and an sRGB output clamps at the framebuffer.
166
+ */
167
+ declare function oklabToLinearSrgb(lightness: number, greenRed: number, blueYellow: number): [number, number, number];
168
+ /** OKLch (L, C, h-in-degrees) -> extended linear-sRGB. */
169
+ declare function oklchToLinearSrgb(lightness: number, chroma: number, hueDegrees: number): [number, number, number];
170
+ /**
171
+ * Parse a color string to **extended** linear-sRGB. Accepts `#rrggbb`,
172
+ * `oklab(L a b)`, and `oklch(L C H)` (CSS Color 4 syntax: L/C may be percentages,
173
+ * H may carry a `deg` suffix, an optional `/ alpha` is parsed and dropped).
174
+ * Throws on any other syntax.
175
+ */
176
+ declare function parseColorString(input: string): [number, number, number];
177
+
178
+ /**
179
+ * sRGB-encoded channel in [0,1] -> linear-sRGB. Standard sRGB EOTF.
180
+ * Mirrors three's `convertSRGBToLinear` (e.g. 0.5 -> 0.21404114).
181
+ */
182
+ declare function srgbChannelToLinear(channel: number): number;
127
183
 
128
184
  /**
129
185
  * 2D simplex noise sampled at a point. Returns a scalar TSL node in
@@ -138,9 +194,9 @@ declare function colorRamp(t: TSLNode, stops: ColorRampStop[]): ShaderNodeObject
138
194
  * Returns `ShaderNodeObject<Node>` (chainable) rather than the broader
139
195
  * `TSLNode` union, so callers can `.add(...)`/`.mul(...)` without casting.
140
196
  */
141
- declare function noise(p: TSLNode): ShaderNodeObject<Node>;
197
+ declare function simplexNoise(p: TSLNode): ShaderNodeObject<Node>;
142
198
 
143
- interface FBMOptions {
199
+ interface FractalNoiseOptions {
144
200
  /** Number of octaves to sum. JS-side number — fixed at TSL build time, not a uniform. Default: 4. */
145
201
  octaves?: number;
146
202
  /** Per-octave frequency multiplier. JS-side number. Default: 2. */
@@ -171,7 +227,7 @@ interface FBMOptions {
171
227
  * @returns scalar TSL node, normalized to roughly [-1..1] regardless of
172
228
  * octave count thanks to the amplitude-sum division at the end.
173
229
  */
174
- declare function fbm(p: TSLNode, opts?: FBMOptions): ShaderNodeObject<Node>;
230
+ declare function fractalNoise(p: TSLNode, opts?: FractalNoiseOptions): ShaderNodeObject<Node>;
175
231
 
176
232
  /**
177
233
  * 2D voronoi (Worley) noise — distance to the nearest jittered cell point,
@@ -209,7 +265,7 @@ declare function quantize(t: ShaderNodeObject<Node>, steps: number): ShaderNodeO
209
265
  * @param p — Vec2 TSL node (typically a UV-space offset from the center).
210
266
  * @param radius — JS-side scalar OR a scalar TSL node.
211
267
  */
212
- declare function sdfCircle(p: TSLNode, radius: TSLNode | number): ShaderNodeObject<Node>;
268
+ declare function signedDistanceFieldCircle(p: TSLNode, radius: TSLNode | number): ShaderNodeObject<Node>;
213
269
 
214
270
  /**
215
271
  * Naive vector addition: returns `p + by`.
@@ -256,40 +312,26 @@ interface CursorRippleOptions {
256
312
  */
257
313
  declare function cursorRipple(p: TSLNode, center: TSLNode, opts?: CursorRippleOptions): ShaderNodeObject<Node>;
258
314
 
259
- declare const time: ShaderNodeObject<Node>;
315
+ declare const elapsedTime: ShaderNodeObject<Node>;
316
+
317
+ type TSLScalar = TSLNode | number;
318
+ declare function filmGrain(intensity: TSLScalar, timeOffset?: TSLScalar): ShaderNodeObject<Node>;
260
319
 
261
320
  /**
262
- * Hash-based film grain chaotic, uncorrelated per-pixel noise sampled
263
- * from `uvNode`. The output is *centered* around zero so it acts as a
264
- * brightness-preserving texture overlay (half the pixels brighten by up
265
- * to `intensity`, half darken, mean unchanged). ADD the result to a color.
266
- *
267
- * filmGrain(uv, k) static grain
268
- * filmGrain(uv, k, time) → twinkling grain. Pass a quantized time node
269
- * (e.g. `time.mul(speed).mul(60).floor()`) so
270
- * the grain re-randomizes at a controllable
271
- * "shutter rate" instead of every frame.
272
- *
273
- * Recipe:
274
- *
275
- * base = vec2(uv·c1, uv·c2) + timeOffset
276
- * hash = fract(sin(base) * 43758.5453)
277
- * out = (length(hash) - 0.765) * intensity
278
- *
279
- * `c1 = (2127.1, 81.17)` and `c2 = (1269.5, 283.37)` are arbitrary
280
- * near-prime constants that produce visually-uncorrelated noise. `0.765`
281
- * is the empirical mean of `length(vec2(u, v))` for uniform u, v ∈ [0, 1),
282
- * computed once so we don't have to subtract it at runtime per pixel.
283
- *
284
- * For a film-stock look (darkens as grain rises — silver-emulsion
285
- * physics) subtract the result from the color instead of adding.
286
- *
287
- * @param uvNode vec2 TSL node, typically `uv()`.
288
- * @param intensity number or TSL node in [0, 1]; scales the grain.
289
- * @param timeOffset optional number or TSL node added to each sample
290
- * before hashing. `0` (default) → static grain.
321
+ * Add sub-LSB dither to break up 8-bit quantization banding (most visible on
322
+ * wide-gamut/P3 output, where the same 256 levels span a wider gamut).
323
+ *
324
+ * `coord` is a per-pixel coordinate (pass `uv()`); `amount` is the noise
325
+ * magnitude in the color's units (default ~1/255, roughly one 8-bit step). Uses a
326
+ * triangular PDF (difference of two hashes) for flatter, less "gritty" noise than
327
+ * uniform white noise.
328
+ *
329
+ * SPIKE NOTE: applied here in linear-sRGB working space (before the renderer's
330
+ * output transfer), so the effective dither is uneven across tones (over-dithers
331
+ * shadows, under-dithers highlights). The correct home is a final output pass
332
+ * after color-space conversion; this is good enough to evaluate the banding fix.
291
333
  */
292
- declare function filmGrain(uvNode: ShaderNodeObject<Node>, intensity: ShaderNodeObject<Node> | number, timeOffset?: ShaderNodeObject<Node> | number): ShaderNodeObject<Node>;
334
+ declare function dither(color: TSLNode, coord: TSLNode, amount?: number): ShaderNodeObject<Node>;
293
335
 
294
336
  type ReducedMotionPolicy = 'auto' | 'off' | 'slow' | 'paused';
295
337
  /**
@@ -325,7 +367,7 @@ declare function createReducedMotionWatcher(): ReducedMotionWatcher;
325
367
  * imperatively when policy changes is safe — TSL re-reads the uniform every
326
368
  * frame.
327
369
  */
328
- declare function getReducedMotionTimeScale(): ShaderNodeObject<Node>;
370
+ declare function getReducedMotionTimeScale(): ReturnType<typeof uniform<number>>;
329
371
 
330
372
  interface VisibilityWatcher {
331
373
  isVisible(): boolean;
@@ -358,28 +400,23 @@ interface IntersectionWatcher {
358
400
  declare function createIntersectionWatcher(canvas: HTMLCanvasElement): IntersectionWatcher;
359
401
 
360
402
  interface SchedulerTick {
361
- /** Seconds since the previous tick. 0 on the first call. */
362
403
  delta: number;
363
- /** Total seconds since the scheduler started its current run. */
364
404
  elapsed: number;
365
- /** The raw `performance.now()` timestamp the rAF callback received. */
366
405
  now: number;
367
406
  }
368
407
  type SchedulerClient = (tick: SchedulerTick) => void;
369
- /**
370
- * Batches `requestAnimationFrame` calls across all clients registered with
371
- * a single scheduler. One scheduler is created per <ShaderScene>; clients
372
- * are typically a Three.js renderer's render call.
373
- */
374
408
  declare class FrameScheduler {
375
409
  private readonly clients;
376
410
  private rafId;
377
411
  private running;
378
412
  private paused;
379
- private idle;
380
413
  private flushPending;
381
414
  private startedAt;
382
415
  private lastTickAt;
416
+ private idleVotes;
417
+ private animatedVotes;
418
+ /** True when all participating components prefer idle and none need animation. */
419
+ get idle(): boolean;
383
420
  /** Activate the scheduler. The rAF loop starts on the first client added. */
384
421
  start(): void;
385
422
  /** Halt the rAF loop entirely. Use dispose() for permanent teardown. */
@@ -395,16 +432,25 @@ declare class FrameScheduler {
395
432
  /** Permanent teardown: stop the loop and drop all clients. */
396
433
  dispose(): void;
397
434
  /**
398
- * Mark the scheduler idle. The next tick still fires (a final flush so
399
- * uniform changes that triggered the idle state are rendered), then the
400
- * rAF loop halts. Use `requestRender()` or `setIdle(false)` to wake.
435
+ * Cast a vote on whether the scheduler should be idle.
436
+ *
437
+ * `setIdle(true)` increments the idle-vote count; the returned cleanup
438
+ * decrements it. `setIdle(false)` increments the animated-vote count;
439
+ * its cleanup decrements that. The scheduler halts (after one flush tick)
440
+ * only when `idleVotes > 0 && animatedVotes === 0`.
441
+ *
442
+ * Callers are responsible for calling the returned cleanup on unmount.
443
+ * Use `requestRender()` or cast a `setIdle(false)` vote to wake the loop
444
+ * without permanently registering an animated preference.
401
445
  */
402
- setIdle(idle: boolean): void;
446
+ setIdle(idle: boolean): () => void;
403
447
  /** Force a single tick while idle. Useful for prop-change invalidation. */
404
448
  requestRender(): void;
449
+ private onBecameIdle;
450
+ private onBecameAnimated;
405
451
  private maybeQueue;
406
452
  private cancel;
407
453
  private readonly frame;
408
454
  }
409
455
 
410
- export { type ColorRampStop, type CreateRendererOptions, CursorInput, type CursorInputOptions, type CursorRippleOptions, type FBMOptions, FrameScheduler, type GpuBackend, type GpuRenderer, type IntersectionWatcher, type ReducedMotionPolicy, type ReducedMotionWatcher, type SchedulerClient, type SchedulerTick, type TSLNode, type Vec2, type VisibilityWatcher, colorRamp, createIntersectionWatcher, createReducedMotionWatcher, createRenderer, createVisibilityWatcher, cursorRipple, displace, fbm, filmGrain, getReducedMotionPolicy, getReducedMotionTimeScale, noise, quantize, sdfCircle, setReducedMotionPolicy, time, voronoi };
456
+ export { type ColorRampStop, type ColorSpace, type CreateRendererOptions, CursorInput, type CursorInputOptions, type CursorRippleOptions, type FractalNoiseOptions, FrameScheduler, type GpuBackend, type GpuRenderer, type HueInterpolation, type IntersectionWatcher, type OutputGamut, type ReducedMotionPolicy, type ReducedMotionWatcher, type SchedulerClient, type SchedulerTick, type TSLNode, type Vector2, type VisibilityWatcher, colorRamp, createIntersectionWatcher, createReducedMotionWatcher, createRenderer, createVisibilityWatcher, cursorRipple, displace, dither, elapsedTime, filmGrain, fractalNoise, getReducedMotionPolicy, getReducedMotionTimeScale, mixColor, oklabToLinearSrgb, oklchToLinearSrgb, parseColorString, quantize, setReducedMotionPolicy, signedDistanceFieldCircle, simplexNoise, srgbChannelToLinear, voronoi };