creativescala / doodle

Compositional vector graphics in Scala / Scala.JS
https://creativescala.org/doodle/
Apache License 2.0
327 stars 75 forks source link

Moving the mouse over the canvas changes its update speed #171

Closed lego-eden closed 1 month ago

lego-eden commented 1 month ago

When experimenting with Reactor I found out that at sufficiently high update-rate (I used .withUpdateRate(5.millis)), the animation on the canvas speeds up while moving the mouse.

I have tried copying the code for the BaseReactor and the Reactor, removing the references to mouse-movement and mouse-clicking, but the bug is still present. I suspect this is an issue to do with the Canvas in java2d.

Happy for any help or tips on how I can solve this.

https://github.com/user-attachments/assets/136b58ce-f766-4c6a-9b45-96240c0ff79e

lego-eden commented 1 month ago

I have now come up with a (temporary?) fix for this issue. I have rewritten the run method in BaseReactor. It now looks like this:

  def run[Alg <: Basic, Frame, Canvas](frame: Frame)(implicit
      a: AnimationRenderer[Canvas],
      e: Renderer[Alg, Frame, Canvas],
      m: MouseClick[Canvas] & MouseMove[Canvas],
      runtime: IORuntime
  ): Unit = {
    import BaseReactor.*

    def mouseEventProducer(mouseEventQueue: Queue[IO, MouseEvent], canvas: Canvas): IO[Unit] = {
      val mouseMove  = canvas.mouseMove.map(pt => MouseMove(pt))
      val mouseClick = canvas.mouseClick.map(pt => MouseClick(pt))

      mouseMove.merge(mouseClick)
        .foreach(mouseEventQueue.offer)
        .compile
        .drain
    }

    def tickProducer(tickQueue: Queue[IO, A], mouseEventQueue: Queue[IO, MouseEvent]): IO[Unit] = {
      Stream
        .fixedRate[IO](this.tickRate)
        .evalScan[IO, A](this.initial)((prev, _) =>
          def drainMouseQueue(a: A): IO[A] =
            for
              mouseEvent <- mouseEventQueue.tryTake
              state <- mouseEvent match
                case Some(MouseMove(pt))  => drainMouseQueue(this.onMouseMove(pt, a))
                case Some(MouseClick(pt)) => drainMouseQueue(this.onMouseClick(pt, a))
                case None => IO.pure(a)
            yield state

          for 
            mouseState <- drainMouseQueue(prev)
            state = this.onTick(mouseState)
          yield state
        )
        .takeWhile(a => !this.stop(a))
        .foreach(tickQueue.offer)
        .compile
        .drain
    }

    def consumer(tickQueue: Queue[IO, A], canvas: Canvas): IO[Unit] = {
      Stream.unit.repeat
        .evalScan[IO, A](this.initial)((prev, _) =>
          for
            maybeTaken <- tickQueue.tryTake
            state = maybeTaken match
              case Some(a) => a
              case None => prev
          yield state
        )
        .takeWhile(a => !this.stop(a))
        .map(a => Image.compile[Alg](this.render(a)))
        .animateWithCanvasToIO(canvas)
    }

    (
      for
        canvas <- frame.canvas[Alg, Canvas]()
        tickQueue <- Queue.circularBuffer[IO, A](1)
        // mouseEventQueue <- Queue.circularBuffer[IO, MouseEvent](1)
        mouseEventQueue <- Queue.unbounded[IO, MouseEvent]
        _ <-
          (mouseEventProducer(mouseEventQueue, canvas), tickProducer(tickQueue, mouseEventQueue), consumer(tickQueue, canvas))
            .parMapN((_, _, _) => ())
      yield ()
    ).unsafeRunAsync(_ => ())
  }
}

The main difference between this method and the existing one, is that this one has split the ticks into their own fiber, thus decoupling the tickRate completely from the canvas, bypassing the issue. I have also decided to only evaluate the mouse-events when a tick occurs. They keep piling up in a queue, which is then emptied the next tick. The mouse-events are evaluated as they are taken from the queue.

https://github.com/user-attachments/assets/bb90a67c-88fa-4b54-9f82-bb8734f905fb

noelwelsh commented 1 month ago

Interesting. I suspect the root cause is somewhere in the depths of the Java2D framework it's redrawing the screen when the mouse moves, and this is triggering whatever ends up invoking tick. I haven't looked at that code in quite a while, so I don't recall how it works.

You've taken what seems a reasonable approach to me. Writing Java2D code is, at this point, as much archaeology as it is programming. Documentation tends to be very old. At the moment I don't have a strong opinion about doing that archaeology, vs taking the approach you have, vs looking more closely at an alternative like Skija.

noelwelsh commented 1 month ago

Closed by #172