rive-app / rive-wasm

Wasm/JS runtime for Rive
MIT License
669 stars 46 forks source link

Request for a js game engine friendly API #262

Open jrabek opened 1 year ago

jrabek commented 1 year ago

Hi! First up Rive is awesome and everything I've seen so far is great! 🙏

I am looking at creating a Phaser (https://phaser.io/) plugin to support Rive and make for a better creative pipeline for development for us. Can definitely open source the plugin later once it is working.

My question is about writing code at the right level of abstraction since Rive has high and low level APIs.

I already did a proof of concept in Phaser using the low level render loop based on the various examples in Rive's repos and documentation.

I was realizing though that https://github.com/rive-app/rive-wasm/blob/master/js/src/rive.ts handles a lot of cases and provides a lot of functionality (events, looping, etc). The challenge in integrating it into Phaser is that the high level Rive API is designed to be loaded with a single riv file and a single instance of an artboard.

For a game it seems you would want to take more of an approach like in this example from Rive (https://codesandbox.io/s/rive-canvas-advanced-api-centaur-example-exh2os?file=/src/index.ts:4022-4036) where you load the rive file once then create instances of artboards from it. If you want this level of control you lose all the other functionality in rive.ts, or have to copy that file and manually apply updates which seems error prone.

Specifically what would be useful to do (at least for Phaser integration and I imagine other js engines) would be a factory approach. Each factory instance would be constructed with a single riv file and could produce instances of Rive objects that would have play, pause onLoop on them. That way there is only a single copy of the riv file in memory and it is only parsed once and could even be parsed at load time rather than at play time.

Then, at least in the Phaser case, there would be a GameObject wrapper around the object produced by the factory (i.e. the binding with Phaser), that would set the canvas to use on the instanced rive object, would call update with a time delta to advance the animation, and then render so that it could take the canvas passed in and then composite it within the game.

jrabek commented 1 year ago

Another ask would be for more explicit control of the render loop. Right now it is required to call rive.requestAnimationFrame because it has side effects related to the renderer that actually cause the rendering to happen.

In many game engines there is an explicit render call that is made at the point a game object should render itself to a scene. This is already being driven off of the native requestAnimationFrame. There is also a preUpdate call which informs the time delta since in game time can be sped up or slowed down independent of the rate of the calls of requestAnimationFrame

This means in order to work inside of Phaser it is necessary to have a dummy rive.requestAnimationFrame call so that the rendering actually happens.

To clarify (just showing the relevant code from the class here):

  play(animationName?: string): void {
    const artboard = this.artboard;
    if (!artboard) {
      console.warn("No artboard loaded.  Cannot play ", animationName);
      return;
    }

    let animation: LinearAnimationInstance | undefined;
    if (!animationName) {
      animation = artboard.animationByIndex(0);
    } else {
      animation = artboard.animationByName(animationName);
    }

    if (!animation) {
      console.warn("No animation with name", { animationName });
      return;
    }

    const rive = RiveObjectFactory.getRuntime();
    const instance = new rive.LinearAnimationInstance(animation, artboard);
    this.animate = (delta: number) => {
      instance.advance(delta);
      instance.apply(1.0);
    };
    this.animate(0);

    this.animationName = animationName;
    this.emit(RiveEvent.Play, { animationName });

    this.requestAnimationFrame();
  }

  preUpdate(time: number, delta: number): void {
    this.delta = delta / 1000;
  }

  renderCanvas(renderer: Phaser.Renderer.Canvas.CanvasRenderer, camera: Phaser.Cameras.Scene2D.Camera, calcMatrix: Phaser.Math.Matrix4): void {
    if (!this.loaded || this.delta === 0) {
      console.log("Not rendering", { loaded: this.loaded, delta: this.delta });
      return;
    }
    const rive = this.rive;
    const artboard = this.artboard;    
    const animate = this.animate;
    const stateMachine = this.stateMachine;
    const activeAnimation = !!(animate || stateMachine);
    const canvas = this.canvasTexture?.canvas;
    if (!rive || !artboard || !canvas || !activeAnimation) {
      console.log("Not rendering", { rive, artboard, canvasElement: canvas, activeAnimation });
      return;
    }

    const riveRenderer = this.canvasRenderer;
    if (!riveRenderer) {
      console.log("Not rendering", { riveRenderer });
      return;
    }
    riveRenderer.clear();
    this.stateMachine?.advance(this.delta);
    animate?.(this.delta);
    artboard.advance(this.delta);
    this.delta = 0;
    riveRenderer.save();
    riveRenderer.align(this.fit, this.alignment, {
      minX: 0,
      minY: 0,
      maxX: this.width,
      maxY: this.height
    }, artboard.bounds);
    artboard.draw(riveRenderer);
    const dctx = renderer.currentContext;
    const x = this.x - this.originX * this.width;
    const y = this.y - this.originY * this.height;
    dctx.drawImage(canvas, x, y);
    riveRenderer.restore();
    riveRenderer.flush();
  }

  private draw(/*elapsedTime: number*/): void {
    this.requestAnimationFrame();
  }

  private requestAnimationFrame() {
    this.rive.requestAnimationFrame(this.draw.bind(this));
  }
jrabek commented 1 year ago

By side effects I am specifically referring to https://github.com/rive-app/rive-wasm/blob/9c5bbff1d8a361d387a4d215a33437ea2ca9c3a2/wasm/js/skia_renderer.js#L294

jrabek commented 1 year ago

Here is how I am using Rive in Phaser for context: https://github.com/jrabek/rive-phaser-plugin