@hdcodedev/snowfall 1.0.13 → 1.0.14

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.mts CHANGED
@@ -23,6 +23,10 @@ interface PhysicsConfig {
23
23
  };
24
24
  MAX_SURFACES: number;
25
25
  COLLISION_CHECK_RATE: number;
26
+ /**
27
+ * Optional cap for render DPR. Lower values reduce GPU load on high-DPI displays.
28
+ */
29
+ MAX_RENDER_DPR?: number;
26
30
  }
27
31
  declare const DEFAULT_PHYSICS: PhysicsConfig;
28
32
  interface PerformanceMetrics {
package/dist/index.d.ts CHANGED
@@ -23,6 +23,10 @@ interface PhysicsConfig {
23
23
  };
24
24
  MAX_SURFACES: number;
25
25
  COLLISION_CHECK_RATE: number;
26
+ /**
27
+ * Optional cap for render DPR. Lower values reduce GPU load on high-DPI displays.
28
+ */
29
+ MAX_RENDER_DPR?: number;
26
30
  }
27
31
  declare const DEFAULT_PHYSICS: PhysicsConfig;
28
32
  interface PerformanceMetrics {
package/dist/index.js CHANGED
@@ -54,8 +54,9 @@ var DEFAULT_PHYSICS = {
54
54
  MAX: 1.6
55
55
  },
56
56
  MAX_SURFACES: 15,
57
- COLLISION_CHECK_RATE: 0.3
57
+ COLLISION_CHECK_RATE: 0.3,
58
58
  // 30% of snowflakes check collisions per frame
59
+ MAX_RENDER_DPR: 1.25
59
60
  };
60
61
  var SnowfallContext = (0, import_react.createContext)(void 0);
61
62
  function SnowfallProvider({ children, initialDebug = false, initialEnabled = true }) {
@@ -381,7 +382,7 @@ var updateSnowflakePosition = (flake, dt) => {
381
382
  flake.x += (flake.wind + Math.sin(flake.wobble) * 0.5) * dt;
382
383
  flake.y += (flake.speed + Math.cos(flake.wobble * 0.5) * 0.1) * dt;
383
384
  };
384
- var checkSideCollision = (flake, flakeViewportX, flakeViewportY, rect, acc, config) => {
385
+ var checkSideCollision = (flakeViewportX, flakeViewportY, rect, acc, config) => {
385
386
  const isInVerticalBounds = flakeViewportY >= rect.top && flakeViewportY <= rect.bottom;
386
387
  if (!isInVerticalBounds || acc.maxSideHeight <= 0) {
387
388
  return false;
@@ -461,7 +462,7 @@ var updateSnowflakes = (snowflakes, elementRects, config, dt, worldWidth, worldH
461
462
  const { rect, acc } = item;
462
463
  const isBottom = acc.type === VAL_BOTTOM;
463
464
  if (!landed && !isBottom) {
464
- landed = checkSideCollision(flake, flakeViewportX, flakeViewportY, rect, acc, config);
465
+ landed = checkSideCollision(flakeViewportX, flakeViewportY, rect, acc, config);
465
466
  if (landed) break;
466
467
  }
467
468
  if (!landed) {
@@ -563,11 +564,11 @@ var import_react3 = require("react");
563
564
  function useSnowfallCanvas() {
564
565
  const canvasRef = (0, import_react3.useRef)(null);
565
566
  const dprRef = (0, import_react3.useRef)(1);
566
- const resizeCanvas = (0, import_react3.useCallback)(() => {
567
+ const resizeCanvas = (0, import_react3.useCallback)((maxRenderDpr = Number.POSITIVE_INFINITY) => {
567
568
  if (canvasRef.current) {
568
569
  const newWidth = window.innerWidth;
569
570
  const newHeight = window.innerHeight;
570
- const dpr = window.devicePixelRatio || 1;
571
+ const dpr = Math.min(window.devicePixelRatio || 1, Math.max(1, maxRenderDpr));
571
572
  dprRef.current = dpr;
572
573
  canvasRef.current.width = newWidth * dpr;
573
574
  canvasRef.current.height = newHeight * dpr;
@@ -588,20 +589,41 @@ var import_react4 = require("react");
588
589
  // src/core/draw.ts
589
590
  var ACC_FILL_STYLE = "rgba(255, 255, 255, 0.95)";
590
591
  var ACC_SHADOW_COLOR = "rgba(200, 230, 255, 0.6)";
592
+ var OPACITY_BUCKETS2 = [0.3, 0.5, 0.7, 0.9];
591
593
  var drawSnowflakes = (ctx, flakes) => {
592
594
  if (flakes.length === 0) return;
593
595
  ctx.fillStyle = "#FFFFFF";
594
- for (const flake of flakes) {
595
- if (!flake.isBackground) {
596
- ctx.globalAlpha = flake.glowOpacity;
597
- ctx.beginPath();
596
+ for (const alpha of OPACITY_BUCKETS2) {
597
+ let hasPath = false;
598
+ for (const flake of flakes) {
599
+ if (flake.isBackground || flake.glowOpacity !== alpha) continue;
600
+ if (!hasPath) {
601
+ ctx.globalAlpha = alpha;
602
+ ctx.beginPath();
603
+ hasPath = true;
604
+ }
605
+ ctx.moveTo(flake.x + flake.glowRadius, flake.y);
598
606
  ctx.arc(flake.x, flake.y, flake.glowRadius, 0, TAU);
607
+ }
608
+ if (hasPath) {
609
+ ctx.fill();
610
+ }
611
+ }
612
+ for (const alpha of OPACITY_BUCKETS2) {
613
+ let hasPath = false;
614
+ for (const flake of flakes) {
615
+ if (flake.opacity !== alpha) continue;
616
+ if (!hasPath) {
617
+ ctx.globalAlpha = alpha;
618
+ ctx.beginPath();
619
+ hasPath = true;
620
+ }
621
+ ctx.moveTo(flake.x + flake.radius, flake.y);
622
+ ctx.arc(flake.x, flake.y, flake.radius, 0, TAU);
623
+ }
624
+ if (hasPath) {
599
625
  ctx.fill();
600
626
  }
601
- ctx.globalAlpha = flake.opacity;
602
- ctx.beginPath();
603
- ctx.arc(flake.x, flake.y, flake.radius, 0, TAU);
604
- ctx.fill();
605
627
  }
606
628
  ctx.globalAlpha = 1;
607
629
  };
@@ -697,9 +719,12 @@ var drawSideAccumulations = (ctx, elementRects, scrollX, scrollY) => {
697
719
  // src/hooks/useAnimationLoop.ts
698
720
  function useAnimationLoop(params) {
699
721
  const animationIdRef = (0, import_react4.useRef)(0);
722
+ const animateRef = (0, import_react4.useRef)(() => {
723
+ });
700
724
  const lastTimeRef = (0, import_react4.useRef)(0);
701
725
  const lastMetricsUpdateRef = (0, import_react4.useRef)(0);
702
726
  const elementRectsRef = (0, import_react4.useRef)([]);
727
+ const visibleRectsRef = (0, import_react4.useRef)([]);
703
728
  const dirtyRectsRef = (0, import_react4.useRef)(true);
704
729
  const animate = (0, import_react4.useCallback)((currentTime) => {
705
730
  const {
@@ -717,17 +742,17 @@ function useAnimationLoop(params) {
717
742
  } = params;
718
743
  const canvas = canvasRef.current;
719
744
  if (!canvas) {
720
- animationIdRef.current = requestAnimationFrame(animate);
745
+ animationIdRef.current = requestAnimationFrame(animateRef.current);
721
746
  return;
722
747
  }
723
748
  const ctx = canvas.getContext("2d");
724
749
  if (!ctx) {
725
- animationIdRef.current = requestAnimationFrame(animate);
750
+ animationIdRef.current = requestAnimationFrame(animateRef.current);
726
751
  return;
727
752
  }
728
753
  if (lastTimeRef.current === 0) {
729
754
  lastTimeRef.current = currentTime;
730
- animationIdRef.current = requestAnimationFrame(animate);
755
+ animationIdRef.current = requestAnimationFrame(animateRef.current);
731
756
  return;
732
757
  }
733
758
  const deltaTime = Math.min(currentTime - lastTimeRef.current, 50);
@@ -736,8 +761,8 @@ function useAnimationLoop(params) {
736
761
  metricsRef.current.rafGap = currentTime - lastTimeRef.current;
737
762
  lastTimeRef.current = currentTime;
738
763
  const dt = deltaTime / 16.67;
739
- const frameStartTime = performance.now();
740
- const clearStart = performance.now();
764
+ const frameStartTime = now;
765
+ const clearStart = now;
741
766
  const dpr = dprRef.current;
742
767
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
743
768
  ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
@@ -754,30 +779,42 @@ function useAnimationLoop(params) {
754
779
  }
755
780
  const physicsStart = performance.now();
756
781
  meltAndSmoothAccumulation(elementRectsRef.current, physicsConfigRef.current, dt);
782
+ const docEl = document.documentElement;
783
+ const worldWidth = docEl.scrollWidth;
784
+ const worldHeight = docEl.scrollHeight;
757
785
  updateSnowflakes(
758
786
  snowflakes,
759
787
  elementRectsRef.current,
760
788
  physicsConfigRef.current,
761
789
  dt,
762
- document.documentElement.scrollWidth,
763
- document.documentElement.scrollHeight
790
+ worldWidth,
791
+ worldHeight
764
792
  );
765
793
  metricsRef.current.physicsTime = performance.now() - physicsStart;
766
794
  const drawStart = performance.now();
767
795
  drawSnowflakes(ctx, snowflakes);
768
796
  if (isEnabledRef.current && snowflakes.length < physicsConfigRef.current.MAX_FLAKES) {
797
+ const minFlakeFloor = Math.min(80, physicsConfigRef.current.MAX_FLAKES);
798
+ const shouldForceSpawn = snowflakes.length < minFlakeFloor;
769
799
  const currentFps = getCurrentFps();
770
- const shouldSpawn = currentFps >= 40 || Math.random() < 0.2;
800
+ const isVisible = document.visibilityState === "visible";
801
+ const isUnderFpsThreshold = isVisible && currentFps > 0 && currentFps < 40;
802
+ const shouldSpawn = shouldForceSpawn || !isUnderFpsThreshold || Math.random() < 0.2;
771
803
  if (shouldSpawn) {
772
804
  const isBackground = Math.random() < 0.4;
773
- snowflakes.push(createSnowflake(document.documentElement.scrollWidth, physicsConfigRef.current, isBackground));
805
+ snowflakes.push(createSnowflake(worldWidth, physicsConfigRef.current, isBackground));
774
806
  }
775
807
  }
776
808
  const viewportWidth = window.innerWidth;
777
809
  const viewportHeight = window.innerHeight;
778
- const visibleRects = elementRectsRef.current.filter(
779
- ({ rect }) => rect.right >= 0 && rect.left <= viewportWidth && rect.bottom >= 0 && rect.top <= viewportHeight
780
- );
810
+ const visibleRects = visibleRectsRef.current;
811
+ visibleRects.length = 0;
812
+ for (const item of elementRectsRef.current) {
813
+ const { rect } = item;
814
+ if (rect.right >= 0 && rect.left <= viewportWidth && rect.bottom >= 0 && rect.top <= viewportHeight) {
815
+ visibleRects.push(item);
816
+ }
817
+ }
781
818
  if (visibleRects.length > 0) {
782
819
  drawAccumulations(ctx, visibleRects, scrollX, scrollY);
783
820
  drawSideAccumulations(ctx, visibleRects, scrollX, scrollY);
@@ -792,13 +829,16 @@ function useAnimationLoop(params) {
792
829
  ));
793
830
  lastMetricsUpdateRef.current = currentTime;
794
831
  }
795
- animationIdRef.current = requestAnimationFrame(animate);
832
+ animationIdRef.current = requestAnimationFrame(animateRef.current);
796
833
  }, [params]);
834
+ (0, import_react4.useEffect)(() => {
835
+ animateRef.current = animate;
836
+ }, [animate]);
797
837
  const start = (0, import_react4.useCallback)(() => {
798
838
  lastTimeRef.current = 0;
799
839
  lastMetricsUpdateRef.current = 0;
800
- animationIdRef.current = requestAnimationFrame(animate);
801
- }, [animate]);
840
+ animationIdRef.current = requestAnimationFrame(animateRef.current);
841
+ }, []);
802
842
  const stop = (0, import_react4.useCallback)(() => {
803
843
  cancelAnimationFrame(animationIdRef.current);
804
844
  }, []);
@@ -846,7 +886,10 @@ function Snowfall() {
846
886
  }, [isEnabled]);
847
887
  (0, import_react5.useEffect)(() => {
848
888
  physicsConfigRef.current = physicsConfig;
849
- }, [physicsConfig]);
889
+ if (isMounted) {
890
+ resizeCanvas(physicsConfig.MAX_RENDER_DPR);
891
+ }
892
+ }, [isMounted, physicsConfig, resizeCanvas]);
850
893
  (0, import_react5.useEffect)(() => {
851
894
  setMetricsRef.current = setMetrics;
852
895
  }, [setMetrics]);
@@ -856,8 +899,10 @@ function Snowfall() {
856
899
  if (!canvas) return;
857
900
  const ctx = canvas.getContext("2d");
858
901
  if (!ctx) return;
859
- resizeCanvas();
902
+ resizeCanvas(physicsConfigRef.current.MAX_RENDER_DPR);
860
903
  snowflakesRef.current = [];
904
+ let isUnmounted = false;
905
+ let scheduledInitFrame = 0;
861
906
  const surfaceObserver = new ResizeObserver((entries) => {
862
907
  let needsUpdate = false;
863
908
  for (const entry of entries) {
@@ -867,10 +912,11 @@ function Snowfall() {
867
912
  }
868
913
  }
869
914
  if (needsUpdate) {
870
- initAccumulationWrapper();
915
+ scheduleAccumulationInit();
871
916
  }
872
917
  });
873
918
  const initAccumulationWrapper = () => {
919
+ if (isUnmounted) return;
874
920
  const scanStart = performance.now();
875
921
  initializeAccumulation(accumulationRef.current, physicsConfigRef.current);
876
922
  surfaceObserver.disconnect();
@@ -880,22 +926,25 @@ function Snowfall() {
880
926
  metricsRef.current.scanTime = performance.now() - scanStart;
881
927
  markRectsDirty();
882
928
  };
929
+ const scheduleAccumulationInit = () => {
930
+ if (scheduledInitFrame !== 0 || isUnmounted) return;
931
+ scheduledInitFrame = requestAnimationFrame(() => {
932
+ scheduledInitFrame = 0;
933
+ initAccumulationWrapper();
934
+ });
935
+ };
883
936
  initAccumulationWrapper();
884
937
  requestAnimationFrame(() => {
885
- if (isMounted) setIsVisible(true);
938
+ if (!isUnmounted && isMounted) setIsVisible(true);
886
939
  });
887
940
  startAnimation();
888
941
  const handleResize = () => {
889
- resizeCanvas();
942
+ resizeCanvas(physicsConfigRef.current.MAX_RENDER_DPR);
890
943
  accumulationRef.current.clear();
891
944
  initAccumulationWrapper();
892
945
  markRectsDirty();
893
946
  };
894
947
  window.addEventListener("resize", handleResize);
895
- const windowResizeObserver = new ResizeObserver(() => {
896
- resizeCanvas();
897
- });
898
- windowResizeObserver.observe(document.body);
899
948
  const mutationObserver = new MutationObserver((mutations) => {
900
949
  let hasStructuralChange = false;
901
950
  for (const mutation of mutations) {
@@ -905,10 +954,7 @@ function Snowfall() {
905
954
  }
906
955
  }
907
956
  if (hasStructuralChange) {
908
- const scanStart = performance.now();
909
- initializeAccumulation(accumulationRef.current, physicsConfigRef.current);
910
- metricsRef.current.scanTime = performance.now() - scanStart;
911
- markRectsDirty();
957
+ scheduleAccumulationInit();
912
958
  }
913
959
  });
914
960
  mutationObserver.observe(document.body, {
@@ -916,9 +962,12 @@ function Snowfall() {
916
962
  subtree: true
917
963
  });
918
964
  return () => {
965
+ isUnmounted = true;
966
+ if (scheduledInitFrame !== 0) {
967
+ cancelAnimationFrame(scheduledInitFrame);
968
+ }
919
969
  stopAnimation();
920
970
  window.removeEventListener("resize", handleResize);
921
- windowResizeObserver.disconnect();
922
971
  mutationObserver.disconnect();
923
972
  surfaceObserver.disconnect();
924
973
  };