three.jsgame-engineclojurescriptshadow-cljs

How can I stop my clojurescript app from blocking browser events while running?


Background

For reference, I am building a little ECS game engine with:

  • clojure
  • threejs as one of the graphics targets
  • shadow-cljs for builds

The full code for the example can be found here.

Problem

When I load the application in the browser, my game engine's main "game loop" blocks threejs from rendering to the screen with requestAnimationFrame, and stops keypresses from being handled until the game loop ends, upon which the functionality for both returns to normal.

So a typical run will look like:

  1. Refresh browser window
  2. I can see main game loop playing out in the console
  3. During the main game loop, nothing displays on the screen and no keypresses are logged.
  4. After the game finishes, the keypress events all "catch up", and are logged at once.
  5. Finally, the threejs renderer renders.

I'm really scratching my head on this one. I've tried:

  • Running the game within a go form, which had no effect.
  • Running the :start-up stage first, then running the rest of the game loop in a setTimeout...
  • Not calling the run fn from init and trying to specify it should be run after load from the config file.

I would expect the key-events to be handled as soon as I press them during the game loop, and I would expect for the threejs renderer to render each time its function is called in the game loop (happens in the draw-scene! system in the supplied code).

Any pointers would be much appreciated.

Code

src/app.cljs

(ns app
  (:require ["three" :as three]
            [snake :refer [add-snake-plugin]]
            [chaos.plugins.core :refer [add-core-plugins]]
            [chaos.plugins.timer :as timer]
            [chaos.engine.world :as chaos :refer [create-world
                                                  defsys
                                                  add-system
                                                  add-system-dependency
                                                  add-stage-dependency]]))

(defsys setup-threejs {}
  (let [w (.-innerWidth js/window)
        h (.-innerHeight js/window)
        aspect (/ w h)
        camera (three/PerspectiveCamera. 75 aspect 0.1 1000)
        renderer (three/WebGLRenderer.)]

    ;; Setup renderer and dom elements.
    (.setSize renderer w h)
    (.. js/document -body (appendChild (.-domElement renderer)))
    ;; Move camera back
    (set! (.. camera -position -z) 5)
    (println "Cam Z:" (.. camera -position -z))
    (println renderer)

    [[:add [:resources :camera] camera]
     [:add [:resources :scene] (three/Scene.)]
     [:add [:resources :renderer] renderer]]))

(defsys add-cube {:resources [:scene]}
  (let [scene (:scene resources)
        geometry (three/BoxGeometry. 1 1 1)
        material (three/MeshBasicMaterial. #js {:color 0x00ff00})
        cube (three/Mesh. geometry material)]
    (.add scene cube)
    []))

(defsys draw-scene! {:resources [:renderer :scene :camera]
                     :events :tick}
  (println "Drawing...")
  (let [{:keys [:renderer :scene :camera]} resources
        render-scene #(.render renderer scene camera)]
    (.. js/window (requestAnimationFrame render-scene))
    []))

(defsys capture-key-down {}
  (let [raw (atom [])
        add-event (fn [event]
                    (println "Keydown event!")
                    (swap! raw conj event))]
    (.addEventListener js/window "keydown" add-event)
    [[:add [:resources :key-down-events] raw]]))

(defsys handle-key-down {:resources [:key-down-events]}
  (println "KEYS" (:key-down-events resources)))

;; ... Pruned some irrelevant systems ...

(defn ^:dev/after-load run []
  (-> (create-world)
      add-core-plugins ;; Main engine plugins (removing has no effect)
      add-snake-plugin ;; The snake game library from another example (as above)
      (add-system :start-up setup-threejs) 
      (add-system :start-up add-cube)
      (add-system-dependency add-cube setup-threejs)
      (add-system :render draw-scene!)

      ;; Set of irrelevant systems which essentially just exit the game after 5 seconds.
      (add-system :start-up add-exit-timer)
      (add-system :pre-step pass-time)
      (add-system exit-after-5)

      ;; Gets key events from window
      (add-system :start-up capture-key-down)

      (add-stage-dependency :render :update)
      chaos/play))

;; shadow-cljs entry point
(defn init []
  (println "Refresh.")
  (run))

shadow-cljs.edn

;; shadow-cljs configuration
{:deps true
 :dev-http {8080 "public"}
 :builds
 {:app {:target :browser
        :modules {:main {:init-fn app/init}}}}}


Solution

  • JavaScript in browsers in single-threaded but async. So when running a piece of code that's not broken up with async or promises (or core.async when it comes to CLJS), it will block everything else apart from web workers.

    Your engine doesn't seem to be using web workers, and chances are it wouldn't even benefit from them, so a plain loop will block everything until it exits.

    A solution to that would be to schedule loop iterations with requestAnimationFrame, where each iteration keeps on scheduling the next one until it encounters a stop condition. So you can't use loop for this scenario, at all - regardless where you use that loop, since it will still block the whole main thread, since there is no any other thread.

    An example of how it would look:

    (defn run-world [state]
      (let [next-state (step state)]
        (when-not (stop? next-state)
          (js/requestAnimationFrame #(run-world next-state)))))
    

    Note that this function also doesn't return anything. But you can make it return either a core.async channel that gets a value when the looping ends, or a Promise that gets resolved eventually.