react-weather-effects 0.1.0-beta.0 → 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Allwin Antony
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  Composable, high-performance rain, snow, and fog visual effects for React, rendered on a single lightweight 2D canvas.
4
4
 
5
+ [![npm version](https://img.shields.io/npm/v/react-weather-effects.svg)](https://www.npmjs.com/package/react-weather-effects)
6
+ [![Live Demo](https://img.shields.io/badge/Demo-Live%20Playground-0070f3?logo=vercel)](https://react-weather-effects-smoky.vercel.app/)
7
+ [![GitHub](https://img.shields.io/badge/GitHub-Repository-181717?logo=github)](https://github.com/allwin-antony/react-weather-effects)
8
+ [![Sponsor](https://img.shields.io/badge/Sponsor-GitHub-ea4aaa?logo=githubsponsors)](https://github.com/sponsors/allwin-antony)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
10
+
11
+ - **Live Demo:** [https://react-weather-effects-smoky.vercel.app/](https://react-weather-effects-smoky.vercel.app/)
12
+ - **GitHub:** [https://github.com/allwin-antony/react-weather-effects](https://github.com/allwin-antony/react-weather-effects)
13
+ - **npm:** [https://www.npmjs.com/package/react-weather-effects](https://www.npmjs.com/package/react-weather-effects)
14
+ - **Sponsor:** [https://github.com/sponsors/allwin-antony](https://github.com/sponsors/allwin-antony)
15
+
5
16
  ## Installation
6
17
 
7
18
  Install `react-weather-effects` using your package manager of choice:
@@ -61,6 +72,7 @@ By default, `<WeatherCanvas>` runs in `mode="fullscreen"`, covering the viewport
61
72
  - **Single Canvas Engine:** One 2D `<canvas>` manages all effects, batching rendering at 60 FPS without DOM bloat.
62
73
  - **Composable:** Combine multiple effects (`Rain`, `Snow`, `Fog`) inside a single canvas.
63
74
  - **Configurable:** Adjust intensity, speed, wind angle, colors, and custom snowflake sprite artwork.
75
+ - **Smooth Scene Changes:** Opt into particle-preserving intensity and density fades with `transitionDuration`.
64
76
  - **Motion-Aware & Accessible:** Automatically respects `prefers-reduced-motion` and pauses when hidden or offscreen.
65
77
  - **Contained & Fullscreen:** Place inside specific cards/heroes (`mode="contained"`) or cover the whole screen.
66
78
  - **SSR & Next.js Safe:** Safe for React Server Components (RSC) and Next.js App Router (runs cleanly with `'use client'`).
@@ -76,6 +88,7 @@ By default, `<WeatherCanvas>` runs in `mode="fullscreen"`, covering the viewport
76
88
  - [Server rendering and Next.js](#server-rendering-and-nextjs)
77
89
  - [Troubleshooting](#troubleshooting)
78
90
  - [Local development](#local-development)
91
+ - [Sponsoring](#sponsoring)
79
92
  - [License](#license)
80
93
 
81
94
  ## Usage examples
@@ -130,6 +143,30 @@ export function RainyScene() {
130
143
 
131
144
  Fog draws behind rain and snow. Rain and snow share a drawing priority; instances at that priority draw in registration order. Each effect has its own settings and can be mounted or removed independently.
132
145
 
146
+ ### Transition between scenes
147
+
148
+ Set `transitionDuration` on each effect to fade intensity or density changes over that many milliseconds. Keep the canvas and effects mounted, and use `0` to hide an effect instead of conditionally removing it:
149
+
150
+ ```tsx
151
+ import { WeatherCanvas, Rain, Snow, Fog } from 'react-weather-effects';
152
+
153
+ export function TransitioningScene({ scene }: { scene: 'rain' | 'snow' | 'clear' }) {
154
+ return (
155
+ <WeatherCanvas>
156
+ <Fog density={scene === 'rain' ? 0.3 : 0} transitionDuration={800} />
157
+ <Rain intensity={scene === 'rain' ? 0.8 : 0} transitionDuration={800} />
158
+ <Snow intensity={scene === 'snow' ? 0.6 : 0} transitionDuration={800} />
159
+ </WeatherCanvas>
160
+ );
161
+ }
162
+ ```
163
+
164
+ - The default is `0`, preserving instant updates. Initial mounting and unmounting are also immediate.
165
+ - Rain and snow fade added or removed particles without resetting the pool. Retained particles stay visible and continue moving; fog keeps its existing wisps and texture.
166
+ - Changing the target mid-fade continues from the current visibility. Negative or non-finite durations are treated as `0`.
167
+ - Fades use the shared simulation clock, so hidden/offscreen time does not advance them. Static and paused presentations apply the latest settings immediately without animating.
168
+ - Only rain/snow `intensity` and fog `density` are transitioned. Other props, including rain's existing velocity smoothing, retain their current behavior.
169
+
133
170
  ### Use custom snow artwork
134
171
 
135
172
  Add a static image to your application's public assets, for example `public/snowflakes/crystal.svg`, and pass its served URL:
@@ -167,7 +204,7 @@ Pass a **URL string**, not an SVG React component or raw SVG markup. PNG, SVG, a
167
204
 
168
205
  For remote images, the image server must allow anonymous cross-origin requests with an appropriate `Access-Control-Allow-Origin` header. Serving artwork from your own application is the simplest option.
169
206
 
170
- `wobble={false}` disables the animated sway and gentle sprite rotation. Wind can still move the flakes. There is no separate `rotation` prop in the current API.
207
+ `wobble={false}` disables animated sideways sway and fall flutter. You can control sprite rotation separately with `rotation` (set `false` to let custom sprites drift upright without rotating, or `true` with `wobble={false}` to rotate without swaying) and adjust the spinning rate with `rotationSpeed` (multiplier, default `1`; set `0` to freeze rotation).
171
208
 
172
209
  ### Pause and resume a scene
173
210
 
@@ -220,21 +257,30 @@ Wind is an effect-specific input, not a velocity in pixels per second. Rain and
220
257
  | Prop | Type | Default | Description |
221
258
  | --- | --- | --- | --- |
222
259
  | `intensity` | `number` | `0.7` | Fraction of this instance's particle budget to draw. `0` draws no rain. |
260
+ | `transitionDuration` | `number` | `0` | Intensity fade duration in milliseconds. Keep mounted and set intensity to `0` to fade out. |
223
261
  | `angle` | `number` | `10` | Degrees from vertical. Positive values lean right; negative values lean left. |
224
262
  | `speed` | `number` | `1` | Multiplier for intrinsic falling speed. |
225
- | `color` | `string` | `'rgba(190, 215, 245, 0.8)'` | CSS color for rain trails. |
263
+ | `dropLength` | `number` | `1` | Multiplier for raindrop streak length (< 1 for fine drizzle, > 1 for heavy downpour). Alias: `length`. |
264
+ | `dropThickness` | `number` | `1` | Multiplier for raindrop stroke thickness (< 1 for fine mist, > 1 for heavy rain). Alias: `thickness`. |
265
+ | `splashes` | `boolean` | `false` | Enable animated bottom-edge splashes for contained cards or heroes. Alias: `splash`. |
266
+ | `color` | `string` | `'rgba(190, 215, 245, 0.8)'` | CSS color for rain trails and splashes. |
267
+
268
+ With `splashes`, visible drops produce small, varied droplets that rise and fall over a softly expanding ground ripple. Splash size follows `dropThickness`; existing impacts finish their short animation when rain stops. Under heavy rain, excess impacts are skipped instead of restarting active splashes. Splashes stay at the current canvas bottom edge after resizing, and paused/static redraws preserve their state. Use contained mode for a defined ground edge; splashes do not detect other DOM surfaces.
226
269
 
227
270
  ### `Snow`
228
271
 
229
272
  | Prop | Type | Default | Description |
230
273
  | --- | --- | --- | --- |
231
274
  | `intensity` | `number` | `0.5` | Fraction of this instance's particle budget to draw. `0` draws no snow. |
275
+ | `transitionDuration` | `number` | `0` | Intensity fade duration in milliseconds. Keep mounted and set intensity to `0` to fade out. |
232
276
  | `speed` | `number` | `1` | Multiplier for intrinsic falling speed and wobble progression. |
233
277
  | `color` | `string` | `'rgba(255, 255, 255, 0.85)'` | CSS color for built-in flakes and the sprite fallback. Does not tint artwork. |
234
278
  | `sprite` | `string` | None | Static image URL. Omitting it uses soft, built-in snowflakes. |
235
279
  | `minSize` | `number` | `1.5`, or `6` with a sprite | Minimum flake radius in CSS pixels. |
236
280
  | `maxSize` | `number` | `4`, or `18` with a sprite | Maximum flake radius in CSS pixels. |
237
- | `wobble` | `boolean` | `true` | Enable animated sway and gentle sprite rotation. |
281
+ | `wobble` | `boolean` | `true` | Enable animated sideways sway and fall flutter. |
282
+ | `rotation` | `boolean` | `true` (or `wobble`) | Enable sprite rotation/spinning. Defaults to `wobble`. Set `false` to drift without rotating. |
283
+ | `rotationSpeed` | `number` | `1` | Multiplier for sprite rotation/spinning speed. Defaults to `1`. Set to `0` to freeze angle. |
238
284
 
239
285
  Use nonnegative sizes, with `minSize <= maxSize`. Built-in flakes have a feathered glow that extends beyond their nominal radius.
240
286
 
@@ -243,6 +289,7 @@ Use nonnegative sizes, with `minSize <= maxSize`. Built-in flakes have a feather
243
289
  | Prop | Type | Default | Description |
244
290
  | --- | --- | --- | --- |
245
291
  | `density` | `number` | `0.4` | Opacity multiplier. `0` draws no fog. |
292
+ | `transitionDuration` | `number` | `0` | Density fade duration in milliseconds. Keep mounted and set density to `0` to fade out. |
246
293
  | `speed` | `number` | `1` | Multiplier for intrinsic drift and shape movement. |
247
294
  | `color` | `string` | `'rgba(220, 225, 235, 0.35)'` | CSS color used to tint the cached fog texture. |
248
295
  | `layers` | `number` | `3` | Layer count, rounded and clamped between `1` and `6`. |
@@ -413,6 +460,12 @@ npm install /path/to/react-weather-effects-0.1.0-beta.0.tgz
413
460
 
414
461
  For implementation contracts, package boundaries, and future plans, see `ARCHITECTURE.md` in the source repository. Keep changes focused, cover rendering and lifecycle behavior with tests, and verify the build, types, and size budget before submitting changes.
415
462
 
463
+ ## Sponsoring
464
+
465
+ If you find `react-weather-effects` useful, please consider sponsoring its ongoing development and maintenance:
466
+
467
+ 💖 [**Sponsor on GitHub**](https://github.com/sponsors/allwin-antony)
468
+
416
469
  ## License
417
470
 
418
471
  Licensed under the MIT license.
package/dist/index.cjs CHANGED
@@ -179,6 +179,7 @@ function WeatherCanvas({
179
179
  const frameState = {
180
180
  ctx,
181
181
  dt,
182
+ isAnimating: shouldAnimateRef.current,
182
183
  width: dimensions.width,
183
184
  height: dimensions.height,
184
185
  dpr: currentDpr,
@@ -491,6 +492,51 @@ function growParticlePool(pool, count) {
491
492
  next.set(pool);
492
493
  return next;
493
494
  }
495
+ function resolveDuration(duration) {
496
+ return Number.isFinite(duration) ? Math.max(0, duration) : 0;
497
+ }
498
+ function createTransition(target, duration) {
499
+ return { target, duration: resolveDuration(duration), remaining: 0 };
500
+ }
501
+ function stepTransition(transition, target, duration, state) {
502
+ const nextDuration = resolveDuration(duration);
503
+ if (target !== transition.target || nextDuration !== transition.duration) {
504
+ transition.target = target;
505
+ transition.duration = nextDuration;
506
+ transition.remaining = nextDuration;
507
+ }
508
+ if (!state.isAnimating || nextDuration === 0) {
509
+ transition.remaining = 0;
510
+ return 1;
511
+ }
512
+ if (transition.remaining === 0) return 1;
513
+ const elapsed = state.dt * 1e3;
514
+ const step = elapsed + 1e-6 >= transition.remaining ? 1 : elapsed / transition.remaining;
515
+ transition.remaining = step === 1 ? 0 : transition.remaining - elapsed;
516
+ return step;
517
+ }
518
+ function createParticleTransition(count, capacity, duration) {
519
+ const visibility = new Float32Array(capacity);
520
+ visibility.fill(1, 0, count);
521
+ return { transition: createTransition(count, duration), visibility, count };
522
+ }
523
+ function updateParticleTransition(particles, targetCount, duration, state) {
524
+ const capacity = state.budget.maxParticles;
525
+ if (particles.visibility.length < capacity) {
526
+ const visibility = new Float32Array(capacity);
527
+ visibility.set(particles.visibility);
528
+ particles.visibility = visibility;
529
+ }
530
+ if (particles.count > capacity) particles.visibility.fill(0, capacity, particles.count);
531
+ const count = Math.min(capacity, Math.max(particles.count, targetCount));
532
+ const step = stepTransition(particles.transition, targetCount, duration, state);
533
+ for (let i = 0; i < count; i++) {
534
+ const target = i < targetCount ? 1 : 0;
535
+ particles.visibility[i] = step === 1 ? target : particles.visibility[i] + (target - particles.visibility[i]) * step;
536
+ }
537
+ particles.count = step === 1 ? targetCount : count;
538
+ }
539
+ var SPLASH_STRIDE = 7;
494
540
  function seedParticle(pool, i, state, settings, isInitial) {
495
541
  const base = i * PARTICLE_STRIDE;
496
542
  const angleRad = settings.angle * Math.PI / 180;
@@ -514,7 +560,7 @@ function seedParticleRange(pool, startIndex, endIndex, state, settings) {
514
560
  seedParticle(pool, i, state, settings, true);
515
561
  }
516
562
  }
517
- function updatePool(pool, count, state, settings) {
563
+ function updatePool(pool, count, state, settings, visibility, splashPool, maxSplashes, splashState) {
518
564
  const dt = state.dt;
519
565
  const width = state.width;
520
566
  const height = state.height;
@@ -523,6 +569,15 @@ function updatePool(pool, count, state, settings) {
523
569
  const sinAngle = Math.sin(angleRad);
524
570
  const cosAngle = Math.cos(angleRad);
525
571
  const response = 1 - Math.exp(-dt * 5);
572
+ if (splashState.active) {
573
+ splashState.active = false;
574
+ for (let s = 0; s < maxSplashes; s++) {
575
+ const sBase = s * SPLASH_STRIDE;
576
+ if (splashPool[sBase + 5] === 0 || splashPool[sBase + 2] >= 1) continue;
577
+ splashPool[sBase + 2] = Math.min(1, splashPool[sBase + 2] + dt * splashPool[sBase + 3]);
578
+ if (splashPool[sBase + 2] < 1) splashState.active = true;
579
+ }
580
+ }
526
581
  for (let i = 0; i < count; i++) {
527
582
  const base = i * PARTICLE_STRIDE;
528
583
  let x = pool[base];
@@ -535,8 +590,28 @@ function updatePool(pool, count, state, settings) {
535
590
  const targetVy = cosAngle * speed + state.wind.y * (60 + depth * 50);
536
591
  vx += (targetVx - vx) * response;
537
592
  vy += (targetVy - vy) * response;
593
+ const prevY = y;
538
594
  x += vx * dt;
539
595
  y += vy * dt;
596
+ if (settings.splashes && prevY < height && y >= height && visibility[i] > 0.05) {
597
+ const elapsed = (y - height) / vy;
598
+ const impactX = x - vx * elapsed;
599
+ if (impactX >= 0 && impactX <= width) {
600
+ const sBase = splashState.head * SPLASH_STRIDE;
601
+ splashState.head = (splashState.head + 1) % maxSplashes;
602
+ if (splashPool[sBase + 5] === 0 || splashPool[sBase + 2] >= 1) {
603
+ const duration = 0.3 + depth * 0.18;
604
+ splashState.active = true;
605
+ splashPool[sBase] = impactX;
606
+ splashPool[sBase + 1] = Math.max(-40, Math.min(40, vx * 0.04));
607
+ splashPool[sBase + 2] = elapsed / duration;
608
+ splashPool[sBase + 3] = 1 / duration;
609
+ splashPool[sBase + 4] = (2.8 + depth * 4.6) * Math.sqrt(Math.max(0.4, Math.min(3, settings.dropThickness)));
610
+ splashPool[sBase + 5] = (0.25 + depth * 0.45) * visibility[i];
611
+ splashPool[sBase + 6] = (depth + i * 0.618) % 1;
612
+ }
613
+ }
614
+ }
540
615
  if (x > width + 100 || x < -100) {
541
616
  x = ((x + 100) % wrapWidth + wrapWidth) % wrapWidth - 100;
542
617
  }
@@ -550,65 +625,162 @@ function updatePool(pool, count, state, settings) {
550
625
  }
551
626
  }
552
627
  }
553
- function drawPool(pool, count, state, settings) {
554
- if (count === 0) return;
628
+ function drawPool(pool, count, state, settings, visibility, splashPool, maxSplashes) {
629
+ if (count === 0 && (!settings.splashes || maxSplashes === 0)) return;
555
630
  const ctx = state.ctx;
556
631
  ctx.strokeStyle = settings.color;
557
632
  ctx.lineCap = "round";
558
- for (let i = 0; i < count; i++) {
559
- const base = i * PARTICLE_STRIDE;
560
- const x = pool[base];
561
- const y = pool[base + 1];
562
- const vx = pool[base + 2];
563
- const vy = pool[base + 3];
564
- const depth = pool[base + 6];
565
- const trailFactor = 0.012 + depth * 0.025;
566
- const fade = Math.max(0, Math.min(1, (y + 25) / 45, (state.height + 35 - y) / 80));
567
- for (let segment = 0; segment < 3; segment++) {
568
- const start = segment / 3 * trailFactor;
569
- const end = (segment + 1) / 3 * trailFactor;
570
- ctx.globalAlpha = pool[base + 4] * fade * Math.pow(1 - segment / 3, 2);
571
- ctx.lineWidth = (0.6 + depth * 1.1) * (1 - segment * 0.2);
633
+ if (count > 0) {
634
+ for (let i = 0; i < count; i++) {
635
+ if (visibility[i] === 0) continue;
636
+ const base = i * PARTICLE_STRIDE;
637
+ const x = pool[base];
638
+ const y = pool[base + 1];
639
+ const vx = pool[base + 2];
640
+ const vy = pool[base + 3];
641
+ const depth = pool[base + 6];
642
+ const trailFactor = (0.012 + depth * 0.025) * settings.dropLength;
643
+ const fade = Math.max(0, Math.min(1, (y + 25) / 45, (state.height + 35 - y) / 80));
644
+ for (let segment = 0; segment < 3; segment++) {
645
+ const start = segment / 3 * trailFactor;
646
+ const end = (segment + 1) / 3 * trailFactor;
647
+ ctx.globalAlpha = pool[base + 4] * fade * visibility[i] * Math.pow(1 - segment / 3, 2);
648
+ ctx.lineWidth = (0.6 + depth * 1.1) * (1 - segment * 0.2) * settings.dropThickness;
649
+ ctx.beginPath();
650
+ ctx.moveTo(x - vx * start, y - vy * start);
651
+ ctx.lineTo(x - vx * end, y - vy * end);
652
+ ctx.stroke();
653
+ }
654
+ }
655
+ }
656
+ if (settings.splashes) {
657
+ ctx.fillStyle = settings.color;
658
+ const thickness = Math.max(0.4, Math.min(3, settings.dropThickness));
659
+ for (let s = 0; s < maxSplashes; s++) {
660
+ const sBase = s * SPLASH_STRIDE;
661
+ const progress = splashPool[sBase + 2];
662
+ if (progress >= 1 || splashPool[sBase + 5] === 0) continue;
663
+ const x = splashPool[sBase];
664
+ const y = state.height;
665
+ const size = splashPool[sBase + 4];
666
+ const alpha = splashPool[sBase + 5];
667
+ const elapsed = progress / splashPool[sBase + 3];
668
+ const drift = splashPool[sBase + 1] * elapsed;
669
+ ctx.globalAlpha = alpha * 0.45 * Math.pow(1 - progress, 1.5);
670
+ ctx.lineWidth = Math.max(0.35, 0.6 * thickness * (1 - progress * 0.4));
671
+ const rx = size * (0.25 + 1.9 * (1 - Math.pow(1 - progress, 3)));
672
+ const ry = Math.max(0.25, rx * 0.19);
572
673
  ctx.beginPath();
573
- ctx.moveTo(x - vx * start, y - vy * start);
574
- ctx.lineTo(x - vx * end, y - vy * end);
674
+ ctx.ellipse(x, y, rx, ry, 0, Math.PI, Math.PI * 2);
575
675
  ctx.stroke();
676
+ for (let bead = 0; bead < 5; bead++) {
677
+ const variation = (splashPool[sBase + 6] + bead * 0.37) % 1;
678
+ const beadProgress = progress / (0.72 + variation * 0.28);
679
+ if (beadProgress >= 1) continue;
680
+ const direction = (bead - 2) * 0.5;
681
+ const peak = size * (0.9 + (1 - Math.abs(direction)) * 1.1 + variation * 0.4);
682
+ const beadX = x + drift + direction * size * (1.5 + variation * 0.7) * beadProgress;
683
+ const beadY = y - peak * 4 * beadProgress * (1 - beadProgress);
684
+ const radius = Math.max(0.3, (0.4 + thickness * 0.35) * (1 - beadProgress * 0.45));
685
+ ctx.globalAlpha = alpha * Math.pow(1 - beadProgress, 1.25);
686
+ ctx.beginPath();
687
+ ctx.arc(beadX, beadY, radius, 0, Math.PI * 2);
688
+ ctx.fill();
689
+ }
576
690
  }
577
691
  }
578
692
  ctx.globalAlpha = 1;
579
693
  }
580
694
  function Rain({
581
695
  intensity = 0.7,
696
+ transitionDuration = 0,
582
697
  angle = 10,
583
698
  color = "rgba(190, 215, 245, 0.8)",
584
- speed = 1
699
+ speed = 1,
700
+ dropLength,
701
+ length,
702
+ dropThickness,
703
+ thickness,
704
+ splashes,
705
+ splash
585
706
  }) {
586
707
  const ticker = React.useContext(TickerCtx);
587
708
  if (!ticker) {
588
709
  throw new Error("<Rain /> must be used inside <WeatherCanvas>");
589
710
  }
590
- const settingsRef = React.useRef({ intensity, angle, color, speed });
711
+ const effectiveDropLength = dropLength ?? length ?? 1;
712
+ const effectiveDropThickness = dropThickness ?? thickness ?? 1;
713
+ const effectiveSplashes = splashes ?? splash ?? false;
714
+ const settingsRef = React.useRef({
715
+ intensity,
716
+ transitionDuration,
717
+ angle,
718
+ color,
719
+ speed,
720
+ dropLength: effectiveDropLength,
721
+ dropThickness: effectiveDropThickness,
722
+ splashes: effectiveSplashes
723
+ });
591
724
  React.useEffect(() => {
592
- settingsRef.current = { intensity, angle, color, speed };
725
+ settingsRef.current = {
726
+ intensity,
727
+ transitionDuration,
728
+ angle,
729
+ color,
730
+ speed,
731
+ dropLength: effectiveDropLength,
732
+ dropThickness: effectiveDropThickness,
733
+ splashes: effectiveSplashes
734
+ };
593
735
  ticker.invalidate();
594
- }, [ticker, intensity, angle, color, speed]);
736
+ }, [
737
+ ticker,
738
+ intensity,
739
+ transitionDuration,
740
+ angle,
741
+ color,
742
+ speed,
743
+ effectiveDropLength,
744
+ effectiveDropThickness,
745
+ effectiveSplashes
746
+ ]);
595
747
  React.useEffect(() => {
596
- let pool = createParticlePool(ticker.getBudget().maxParticles);
748
+ const capacity = ticker.getBudget().maxParticles;
749
+ let pool = createParticlePool(capacity);
750
+ const particles = createParticleTransition(
751
+ Math.round(Math.max(0, Math.min(1, settingsRef.current.intensity)) * capacity),
752
+ capacity,
753
+ settingsRef.current.transitionDuration
754
+ );
597
755
  let initializedCount = 0;
756
+ const maxSplashes = Math.min(128, Math.max(32, Math.round(capacity * 0.5)));
757
+ const splashPool = new Float32Array(maxSplashes * SPLASH_STRIDE);
758
+ const splashState = { head: 0, active: false };
598
759
  const unregister = ticker.register(LAYER.MIDGROUND, (state) => {
599
760
  const settings = settingsRef.current;
600
- const count = Math.round(
761
+ const targetCount = Math.round(
601
762
  Math.max(0, Math.min(1, settings.intensity)) * state.budget.maxParticles
602
763
  );
764
+ updateParticleTransition(particles, targetCount, settings.transitionDuration, state);
765
+ const count = particles.count;
603
766
  pool = growParticlePool(pool, state.budget.maxParticles);
604
767
  if (count > initializedCount) {
605
768
  seedParticleRange(pool, initializedCount, count, state, settings);
606
769
  initializedCount = count;
607
770
  }
608
771
  if (state.dt > 0) {
609
- updatePool(pool, count, state, settings);
772
+ updatePool(
773
+ pool,
774
+ count,
775
+ state,
776
+ settings,
777
+ particles.visibility,
778
+ splashPool,
779
+ maxSplashes,
780
+ splashState
781
+ );
610
782
  }
611
- drawPool(pool, count, state, settings);
783
+ drawPool(pool, count, state, settings, particles.visibility, splashPool, maxSplashes);
612
784
  });
613
785
  return unregister;
614
786
  }, [ticker]);
@@ -674,12 +846,13 @@ function seedSnowflake(pool, i, state, isInitial) {
674
846
  y = -20 - state.random() * 50;
675
847
  }
676
848
  }
849
+ const phase = state.random() * Math.PI * 2;
677
850
  pool[base] = x;
678
851
  pool[base + 1] = y;
679
- pool[base + 2] = 0;
852
+ pool[base + 2] = phase;
680
853
  pool[base + 3] = fallSpeed;
681
854
  pool[base + 4] = 0.3 + depth * 0.6;
682
- pool[base + 5] = state.random() * Math.PI * 2;
855
+ pool[base + 5] = phase;
683
856
  pool[base + 6] = depth;
684
857
  }
685
858
  function seedSnowRange(pool, startIndex, endIndex, state) {
@@ -697,6 +870,7 @@ function updateSnow(pool, count, state, settings) {
697
870
  const base = i * PARTICLE_STRIDE;
698
871
  let x = pool[base];
699
872
  let y = pool[base + 1];
873
+ let angle = pool[base + 2];
700
874
  let phase = pool[base + 5];
701
875
  const baseVy = pool[base + 3];
702
876
  const depth = pool[base + 6];
@@ -704,6 +878,11 @@ function updateSnow(pool, count, state, settings) {
704
878
  phase += dt * (0.65 + depth * 1.4) * settings.speed;
705
879
  pool[base + 5] = phase;
706
880
  }
881
+ if (settings.rotation && settings.rotationSpeed !== 0) {
882
+ const spinDir = i % 2 === 0 ? 1 : -1;
883
+ const spinRate = (0.65 + depth * 1.4) * spinDir;
884
+ angle += dt * spinRate * settings.rotationSpeed;
885
+ }
707
886
  const sway = settings.wobble ? (Math.sin(phase) * (10 + depth * 22) + Math.sin(phase * 0.5) * 8) * settings.speed : 0;
708
887
  const vx = sway + state.wind.x * (50 + depth * 80) * gust;
709
888
  const flutter = settings.wobble ? 1 + Math.sin(phase * 0.8) * 0.12 : 1;
@@ -718,16 +897,17 @@ function updateSnow(pool, count, state, settings) {
718
897
  } else {
719
898
  pool[base] = x;
720
899
  pool[base + 1] = y;
721
- pool[base + 2] = vx;
900
+ pool[base + 2] = angle;
722
901
  }
723
902
  }
724
903
  }
725
- function drawSnow(pool, count, state, settings, texture, sprite) {
904
+ function drawSnow(pool, count, state, settings, texture, sprite, visibility) {
726
905
  if (count === 0) return;
727
906
  const ctx = state.ctx;
728
907
  const aspect = sprite ? sprite.aspect : 1;
729
908
  ctx.fillStyle = settings.color;
730
909
  for (let i = 0; i < count; i++) {
910
+ if (visibility[i] === 0) continue;
731
911
  const base = i * PARTICLE_STRIDE;
732
912
  const x = pool[base];
733
913
  const y = pool[base + 1];
@@ -735,14 +915,14 @@ function drawSnow(pool, count, state, settings, texture, sprite) {
735
915
  const r = Math.max(0, settings.minSize + depth * (settings.maxSize - settings.minSize));
736
916
  if (r === 0) continue;
737
917
  const fade = Math.max(0, Math.min(1, (y + 20) / 45, (state.height + 20 - y) / 55));
738
- ctx.globalAlpha = pool[base + 4] * fade;
918
+ ctx.globalAlpha = pool[base + 4] * fade * visibility[i];
739
919
  if (sprite) {
740
920
  const halfWidth = r * Math.min(1, aspect);
741
921
  const halfHeight = r / Math.max(1, aspect);
742
922
  ctx.save();
743
923
  try {
744
924
  ctx.translate(x, y);
745
- if (settings.wobble) ctx.rotate(Math.sin(pool[base + 5]) * 0.35);
925
+ if (settings.rotation) ctx.rotate(pool[base + 2]);
746
926
  ctx.drawImage(sprite.texture, -halfWidth, -halfHeight, halfWidth * 2, halfHeight * 2);
747
927
  } finally {
748
928
  ctx.restore();
@@ -760,31 +940,60 @@ function drawSnow(pool, count, state, settings, texture, sprite) {
760
940
  }
761
941
  function Snow({
762
942
  intensity = 0.5,
943
+ transitionDuration = 0,
763
944
  speed = 1,
764
945
  color = "rgba(255, 255, 255, 0.85)",
765
946
  sprite,
766
947
  minSize = sprite ? 6 : 1.5,
767
948
  maxSize = sprite ? 18 : 4,
768
- wobble = true
949
+ wobble = true,
950
+ rotation,
951
+ rotationSpeed
769
952
  }) {
770
953
  const ticker = React.useContext(TickerCtx);
771
954
  if (!ticker) {
772
955
  throw new Error("<Snow /> must be used inside <WeatherCanvas>");
773
956
  }
957
+ const effectiveRotation = rotation ?? (rotationSpeed !== void 0 ? true : wobble);
958
+ const effectiveRotationSpeed = rotationSpeed ?? 1;
774
959
  const settingsRef = React.useRef({
775
960
  intensity,
961
+ transitionDuration,
776
962
  speed,
777
963
  color,
778
964
  minSize,
779
965
  maxSize,
780
- wobble
966
+ wobble,
967
+ rotation: effectiveRotation,
968
+ rotationSpeed: effectiveRotationSpeed
781
969
  });
782
970
  const textureRef = React.useRef(null);
783
971
  const spriteRef = React.useRef(null);
784
972
  React.useEffect(() => {
785
- settingsRef.current = { intensity, speed, color, minSize, maxSize, wobble };
973
+ settingsRef.current = {
974
+ intensity,
975
+ transitionDuration,
976
+ speed,
977
+ color,
978
+ minSize,
979
+ maxSize,
980
+ wobble,
981
+ rotation: effectiveRotation,
982
+ rotationSpeed: effectiveRotationSpeed
983
+ };
786
984
  ticker.invalidate();
787
- }, [ticker, intensity, speed, color, minSize, maxSize, wobble]);
985
+ }, [
986
+ ticker,
987
+ intensity,
988
+ transitionDuration,
989
+ speed,
990
+ color,
991
+ minSize,
992
+ maxSize,
993
+ wobble,
994
+ effectiveRotation,
995
+ effectiveRotationSpeed
996
+ ]);
788
997
  React.useEffect(() => {
789
998
  textureRef.current = createSnowTexture(color);
790
999
  ticker.invalidate();
@@ -827,13 +1036,21 @@ function Snow({
827
1036
  };
828
1037
  }, [ticker, sprite]);
829
1038
  React.useEffect(() => {
830
- let pool = createParticlePool(ticker.getBudget().maxParticles);
1039
+ const capacity = ticker.getBudget().maxParticles;
1040
+ let pool = createParticlePool(capacity);
1041
+ const particles = createParticleTransition(
1042
+ Math.round(Math.max(0, Math.min(1, settingsRef.current.intensity)) * capacity),
1043
+ capacity,
1044
+ settingsRef.current.transitionDuration
1045
+ );
831
1046
  let initializedCount = 0;
832
1047
  const unregister = ticker.register(LAYER.MIDGROUND, (state) => {
833
1048
  const settings = settingsRef.current;
834
- const count = Math.round(
1049
+ const targetCount = Math.round(
835
1050
  Math.max(0, Math.min(1, settings.intensity)) * state.budget.maxParticles
836
1051
  );
1052
+ updateParticleTransition(particles, targetCount, settings.transitionDuration, state);
1053
+ const count = particles.count;
837
1054
  pool = growParticlePool(pool, state.budget.maxParticles);
838
1055
  if (count > initializedCount) {
839
1056
  seedSnowRange(pool, initializedCount, count, state);
@@ -850,7 +1067,7 @@ function Snow({
850
1067
  spriteRef.current = createSnowSprite(cachedSprite.image, size) ?? cachedSprite;
851
1068
  }
852
1069
  }
853
- drawSnow(pool, count, state, settings, textureRef.current, spriteRef.current);
1070
+ drawSnow(pool, count, state, settings, textureRef.current, spriteRef.current, particles.visibility);
854
1071
  });
855
1072
  return unregister;
856
1073
  }, [ticker]);
@@ -892,6 +1109,7 @@ function renderRadialGradient(surface, size, color) {
892
1109
  }
893
1110
  function Fog({
894
1111
  density = 0.4,
1112
+ transitionDuration = 0,
895
1113
  speed = 1,
896
1114
  color = "rgba(220, 225, 235, 0.35)",
897
1115
  layers = 3
@@ -900,11 +1118,11 @@ function Fog({
900
1118
  if (!ticker) {
901
1119
  throw new Error("<Fog /> must be used inside <WeatherCanvas>");
902
1120
  }
903
- const settingsRef = React.useRef({ density, speed, color, layers });
1121
+ const settingsRef = React.useRef({ density, transitionDuration, speed, color, layers });
904
1122
  React.useEffect(() => {
905
- settingsRef.current = { density, speed, color, layers };
1123
+ settingsRef.current = { density, transitionDuration, speed, color, layers };
906
1124
  ticker.invalidate();
907
- }, [ticker, density, speed, color, layers]);
1125
+ }, [ticker, density, transitionDuration, speed, color, layers]);
908
1126
  const textureRef = React.useRef(null);
909
1127
  const imageBitmapRef = React.useRef(null);
910
1128
  React.useEffect(() => {
@@ -951,13 +1169,18 @@ function Fog({
951
1169
  }, [ticker, color]);
952
1170
  React.useEffect(() => {
953
1171
  const puffs = [];
1172
+ let currentDensity = Math.max(0, Math.min(1, settingsRef.current.density));
1173
+ const transition = createTransition(currentDensity, settingsRef.current.transitionDuration);
954
1174
  const unregister = ticker.register(LAYER.BACKGROUND, (state) => {
955
1175
  const texture = textureRef.current;
956
1176
  if (!texture) return;
957
1177
  const settings = settingsRef.current;
958
1178
  const numLayers = Math.round(Math.max(1, Math.min(6, settings.layers)));
959
1179
  const count = numLayers * 3;
960
- const baseAlpha = Math.max(0, Math.min(1, settings.density));
1180
+ const targetDensity = Math.max(0, Math.min(1, settings.density));
1181
+ const step = stepTransition(transition, targetDensity, settings.transitionDuration, state);
1182
+ currentDensity = step === 1 ? targetDensity : currentDensity + (targetDensity - currentDensity) * step;
1183
+ const baseAlpha = currentDensity;
961
1184
  if (baseAlpha === 0) return;
962
1185
  for (let i = puffs.length; i < count; i++) {
963
1186
  const layer = Math.floor(i / 3);