import { Atlas, Skia, type SkColor, type SkRSXform, type SkRect, } from "@shopify/react-native-skia"; import { useRef } from "react"; import { useDerivedValue, type SharedValue } from "react-native-reanimated"; import { buildParticleInstances, buildParticleSprite, } from "../draw/particleAtlas"; import { parseColorRgb } from "../theme"; import type { LiveChartPalette } from "../types"; type DegenPack = SharedValue>; type DegenParticlesOverlayProps = { pack: DegenPack; packRevision: SharedValue; /** Display-frame seconds, matching the particle pack's spawn timestamps. */ particleTimestamp: SharedValue; palette: LiveChartPalette; particleSlotCount: number; particleBurstDurationSec: number; particleOpacity: number; colors: string[] | null; }; type ParticlePool = { a: ParticleFrame; b: ParticleFrame; tick: boolean; instances: ReturnType; instancePool: ReturnType; }; type ParticleFrame = { transforms: SkRSXform[]; sprites: SkRect[]; colors: SkColor[]; activeCount: number; }; function makeParticlePool( particleSlotCount: number, spriteSize: number, ): ParticlePool { const instancePool = Array.from({ length: particleSlotCount }, () => ({ x: 0, y: 0, scale: 0, alpha: 0, colorIndex: 0, })); const spriteRect = Skia.XYWHRect(0, 0, spriteSize, spriteSize); const hiddenTransform = Skia.RSXform(0, 0, 0, 0); const hiddenColor = Skia.Color("rgba(0, 0, 0, 0)"); const makeFrame = (): ParticleFrame => ({ transforms: Array.from({ length: particleSlotCount }, () => hiddenTransform), sprites: Array.from({ length: particleSlotCount }, () => spriteRect), colors: Array.from({ length: particleSlotCount }, () => hiddenColor), activeCount: 0, }); return { a: makeFrame(), b: makeFrame(), tick: false, instances: [], instancePool, }; } /** * Renders the degen particle burst with a single `drawAtlas` call. * * Every active particle shares one pre-rasterized white-circle sprite and * differs only in position, scale, color, and opacity — the ideal `drawAtlas` * case. A single per-frame worklet projects the packed ring buffer (layout: * `[x, y, vx, vy, t0, active, size, colorIndex]`, stride = DEGEN_STRIDE = 8; * see `math/degenTick.ts`) into transforms + per-sprite colors, replacing the * old O(slots × 4 derived values + 1 `` each) fan-out with O(1) mappers * and one draw. * * Per-particle color/opacity is baked into the sprite color (`rgba(r,g,b,a)`) * and applied with the `"modulate"` blend mode, which multiplies the white * sprite by each color — so a white AA circle becomes a soft, correctly-tinted, * correctly-faded particle, visually equivalent to the old ``. */ export function DegenParticlesOverlay(props: DegenParticlesOverlayProps) { // Replace the Atlas and its derived values together when the array size changes. return ; } function ParticleAtlas({ pack, packRevision, particleTimestamp, palette, particleSlotCount, particleBurstDurationSec, particleOpacity, colors, }: DegenParticlesOverlayProps) { /* istanbul ignore next -- branch depends on render-time props */ const colorList = colors && colors.length > 0 ? colors : [palette.line]; // White-circle sprite has no per-render inputs; React Compiler memoizes it. const sprite = buildParticleSprite(); // Pre-parse the color list to [r,g,b] tuples so the per-frame worklet never // parses arbitrary color strings (only formats `rgba(...)`). React Compiler // memoizes this on `colorList`. const colorRgb = colorList.map((c) => parseColorRgb(c)); const poolRef = useRef(null); if (poolRef.current === null) { // React permits this predictable lazy-ref initialization: https://react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents // react-doctor-disable-next-line react-doctor/no-ref-current-in-render -- false positive for React's documented lazy-ref exception poolRef.current = makeParticlePool(particleSlotCount, sprite.size); } // Single per-frame worklet. Reads `packRevision` so it re-runs each frame // while a burst is alive (the pack buffer is mutated in place, so its // reference is stable and wouldn't otherwise re-notify). const atlasData = useDerivedValue(() => { const rev = packRevision.get(); const pool = poolRef.current!; pool.tick = !pool.tick; const frame = pool.tick ? pool.a : pool.b; const transforms = frame.transforms; const colorsOut = frame.colors; let activeCount = 0; if (rev >= 0) { const instances = buildParticleInstances( pack.get(), particleSlotCount, particleTimestamp.get(), particleBurstDurationSec, particleOpacity, sprite.radius, pool.instances, pool.instancePool, ); const half = sprite.size / 2; activeCount = instances.length; for (let i = 0; i < activeCount; i++) { const inst = instances[i]; // Center the scaled sprite on (x, y); RSXform has no rotation. transforms[i] = Skia.RSXform( inst.scale, 0, inst.x - inst.scale * half, inst.y - inst.scale * half, ); const [r, g, b] = colorRgb[inst.colorIndex % colorRgb.length]; colorsOut[i] = Skia.Color(`rgba(${r}, ${g}, ${b}, ${inst.alpha})`); } } for (let i = activeCount; i < frame.activeCount; i++) { transforms[i] = Skia.RSXform(0, 0, 0, 0); colorsOut[i] = Skia.Color("rgba(0, 0, 0, 0)"); } frame.activeCount = activeCount; return frame; }, [ pack, packRevision, particleTimestamp, particleSlotCount, particleBurstDurationSec, particleOpacity, sprite, colorRgb, ]); const transforms = useDerivedValue(() => atlasData.get().transforms, [atlasData]); const sprites = useDerivedValue(() => atlasData.get().sprites, [atlasData]); const atlasColors = useDerivedValue(() => atlasData.get().colors, [atlasData]); return ( ); }