@@ -389,8 +389,13 @@ const Index = () => {
389389
390390 // Bloom offscreen canvas (1/4 res)
391391 const bloomCanvasRef = useRef < HTMLCanvasElement | null > ( null ) ;
392+ const bloomWorkCanvasRef = useRef < HTMLCanvasElement | null > ( null ) ;
392393 const bloomFrameRef = useRef ( 0 ) ;
393394
395+ // Rivulet offscreen canvas (updated periodically)
396+ const rivuletCanvasRef = useRef < HTMLCanvasElement | null > ( null ) ;
397+ const rivuletLastUpdateRef = useRef ( 0 ) ;
398+
394399 // Settings ref for animation loop access
395400 const settingsRef = useRef ( settings ) ;
396401 settingsRef . current = settings ;
@@ -713,44 +718,44 @@ const Index = () => {
713718 }
714719
715720 // === LAYER 2.5: RIVULET CHANNELS ===
716- // Render subtle wet paths where drops have traveled
721+ // Render to offscreen canvas periodically (every 150ms) to avoid 32K+ draw calls per frame
717722 const rivMap = sim . rivuletMap ;
718- const rivData = rivMap . getData ( ) ;
719- const rivCols = rivMap . cols ;
720- const rivRows = rivMap . rows ;
721- const rivCell = rivMap . cellSize ;
722- ctx . save ( ) ;
723- for ( let r = 0 ; r < rivRows ; r ++ ) {
724- for ( let c = 0 ; c < rivCols ; c ++ ) {
725- const strength = rivData [ r * rivCols + c ] ;
726- if ( strength > 0.05 ) {
727- ctx . fillStyle = `rgba(0, 5, 15, ${ strength * 0.08 } )` ;
728- ctx . fillRect ( c * rivCell , r * rivCell , rivCell , rivCell ) ;
723+ if ( timestamp - rivuletLastUpdateRef . current > 150 ) {
724+ rivuletLastUpdateRef . current = timestamp ;
725+ if ( ! rivuletCanvasRef . current || rivuletCanvasRef . current . width !== width || rivuletCanvasRef . current . height !== height ) {
726+ const rc = document . createElement ( 'canvas' ) ;
727+ rc . width = width ;
728+ rc . height = height ;
729+ rivuletCanvasRef . current = rc ;
730+ }
731+ const rCtx = rivuletCanvasRef . current . getContext ( '2d' ) ! ;
732+ rCtx . clearRect ( 0 , 0 , width , height ) ;
733+ const rivData = rivMap . getData ( ) ;
734+ const rivCols = rivMap . cols ;
735+ const rivRows = rivMap . rows ;
736+ const rivCell = rivMap . cellSize ;
737+ for ( let r = 0 ; r < rivRows ; r ++ ) {
738+ for ( let c = 0 ; c < rivCols ; c ++ ) {
739+ const strength = rivData [ r * rivCols + c ] ;
740+ if ( strength > 0.05 ) {
741+ rCtx . fillStyle = `rgba(0, 5, 15, ${ strength * 0.08 } )` ;
742+ rCtx . fillRect ( c * rivCell , r * rivCell , rivCell , rivCell ) ;
743+ }
729744 }
730745 }
731746 }
732- ctx . restore ( ) ;
747+ if ( rivuletCanvasRef . current ) {
748+ ctx . drawImage ( rivuletCanvasRef . current , 0 , 0 ) ;
749+ }
733750
734751 // === LAYER 2.6: CONDENSATION MICRO-DROPS ===
735752 if ( ( s . condensation ?? 60 ) > 0 ) {
736753 const now = timestamp ;
737754 if ( now - condensationLastUpdateRef . current > PERF . condensationUpdateInterval || ! condensationCanvasRef . current ) {
738755 condensationLastUpdateRef . current = now ;
739756 const count = Math . floor ( PERF . maxCondensation * ( ( s . condensation ?? 60 ) / 100 ) ) ;
740- const drops = generateCondensation ( width , height , count ) ;
741-
742- // Remove condensation near main droplets (absorption wake)
743- const mainDroplets = sim . getDroplets ( ) ;
744- const filteredDrops = drops . filter ( cd => {
745- for ( let d = 0 ; d < mainDroplets . length ; d ++ ) {
746- const md = mainDroplets [ d ] ;
747- const ddx = cd . x - md . x ;
748- const ddy = cd . y - md . y ;
749- if ( ddx * ddx + ddy * ddy < ( md . baseRadius * 3 ) * ( md . baseRadius * 3 ) ) return false ;
750- }
751- return true ;
752- } ) ;
753- condensationRef . current = filteredDrops ;
757+ const microDrops = generateCondensation ( width , height , count ) ;
758+ condensationRef . current = microDrops ;
754759
755760 // Render to offscreen canvas
756761 if ( ! condensationCanvasRef . current || condensationCanvasRef . current . width !== width ) {
@@ -761,19 +766,30 @@ const Index = () => {
761766 }
762767 const cCtx = condensationCanvasRef . current . getContext ( '2d' ) ! ;
763768 cCtx . clearRect ( 0 , 0 , width , height ) ;
764- for ( const cd of filteredDrops ) {
769+ for ( const cd of microDrops ) {
765770 cCtx . beginPath ( ) ;
766771 cCtx . arc ( cd . x , cd . y , cd . radius , 0 , Math . PI * 2 ) ;
767772 cCtx . fillStyle = `rgba(180, 200, 220, ${ cd . opacity * 0.15 } )` ;
768773 cCtx . fill ( ) ;
769- // Tiny specular dot
770774 if ( cd . radius > 1 ) {
771775 cCtx . beginPath ( ) ;
772776 cCtx . arc ( cd . x - cd . radius * 0.3 , cd . y - cd . radius * 0.3 , Math . max ( 0.5 , cd . radius * 0.25 ) , 0 , Math . PI * 2 ) ;
773777 cCtx . fillStyle = `rgba(255, 255, 255, ${ cd . opacity * 0.4 } )` ;
774778 cCtx . fill ( ) ;
775779 }
776780 }
781+ // Clear condensation near main droplets using canvas compositing (fast)
782+ const mainDroplets = sim . getDroplets ( ) ;
783+ cCtx . globalCompositeOperation = 'destination-out' ;
784+ for ( let d = 0 ; d < mainDroplets . length ; d ++ ) {
785+ const md = mainDroplets [ d ] ;
786+ const clearR = md . baseRadius * 3 ;
787+ cCtx . beginPath ( ) ;
788+ cCtx . arc ( md . x , md . y , clearR , 0 , Math . PI * 2 ) ;
789+ cCtx . fillStyle = 'rgba(0,0,0,1)' ;
790+ cCtx . fill ( ) ;
791+ }
792+ cCtx . globalCompositeOperation = 'source-over' ;
777793 }
778794 if ( condensationCanvasRef . current ) {
779795 ctx . drawImage ( condensationCanvasRef . current , 0 , 0 ) ;
@@ -1433,22 +1449,31 @@ const Index = () => {
14331449 bc . height = bh ;
14341450 bloomCanvasRef . current = bc ;
14351451 }
1436- const bCtx = bloomCanvasRef . current . getContext ( '2d' ) ! ;
1437- bCtx . clearRect ( 0 , 0 , bw , bh ) ;
1438- // Draw bright specular positions as white dots
1452+ if ( ! bloomWorkCanvasRef . current || bloomWorkCanvasRef . current . width !== bw ) {
1453+ const wc = document . createElement ( 'canvas' ) ;
1454+ wc . width = bw ;
1455+ wc . height = bh ;
1456+ bloomWorkCanvasRef . current = wc ;
1457+ }
1458+ const wCtx = bloomWorkCanvasRef . current . getContext ( '2d' ) ! ;
1459+ wCtx . clearRect ( 0 , 0 , bw , bh ) ;
1460+ // Draw bright specular positions as white dots to work canvas
14391461 for ( let i = 0 ; i < droplets . length ; i ++ ) {
14401462 const drop = droplets [ i ] ;
14411463 if ( drop . baseRadius > 5 && drop . opacity > 0.5 ) {
14421464 const bx = ( drop . x - drop . baseRadius * 0.3 ) / 4 ;
14431465 const by = ( drop . y - drop . baseRadius * 0.33 ) / 4 ;
1444- bCtx . beginPath ( ) ;
1445- bCtx . arc ( bx , by , Math . max ( 1 , drop . baseRadius * 0.06 ) , 0 , Math . PI * 2 ) ;
1446- bCtx . fillStyle = 'rgba(255, 255, 255, 0.6)' ;
1447- bCtx . fill ( ) ;
1466+ wCtx . beginPath ( ) ;
1467+ wCtx . arc ( bx , by , Math . max ( 1 , drop . baseRadius * 0.06 ) , 0 , Math . PI * 2 ) ;
1468+ wCtx . fillStyle = 'rgba(255, 255, 255, 0.6)' ;
1469+ wCtx . fill ( ) ;
14481470 }
14491471 }
1472+ // Blur from work canvas to bloom canvas (never self-draw)
1473+ const bCtx = bloomCanvasRef . current . getContext ( '2d' ) ! ;
1474+ bCtx . clearRect ( 0 , 0 , bw , bh ) ;
14501475 bCtx . filter = 'blur(8px)' ;
1451- bCtx . drawImage ( bloomCanvasRef . current , 0 , 0 ) ;
1476+ bCtx . drawImage ( bloomWorkCanvasRef . current , 0 , 0 ) ;
14521477 bCtx . filter = 'none' ;
14531478 }
14541479 if ( bloomCanvasRef . current ) {
@@ -1603,6 +1628,9 @@ const Index = () => {
16031628 condensationCanvasRef . current = null ;
16041629 condensationLastUpdateRef . current = 0 ;
16051630 bloomCanvasRef . current = null ;
1631+ bloomWorkCanvasRef . current = null ;
1632+ rivuletCanvasRef . current = null ;
1633+ rivuletLastUpdateRef . current = 0 ;
16061634 simulationRef . current . populate ( settings ) ;
16071635 const canvas = canvasRef . current ;
16081636 if ( canvas ) {
0 commit comments