Shopify / react-native-skia

High-performance React Native Graphics using Skia
https://shopify.github.io/react-native-skia
MIT License
6.81k stars 438 forks source link

Low Atlas performance on cheap Androids #2521

Open sergeymorkovkin opened 2 months ago

sergeymorkovkin commented 2 months ago

Description

I've implemented a bunnymark demo on top of React native Skia, and I use Atlas for sprite rendering.

It all works perfect on my iPhone 12 mini, and renders 10000 bunnies every frame without any frame drops. Slight frame drops start at 15000, and only become significant after 20-25k bunnies.

However, when running on OPPO A16 (a lower-end Android smartphone), the maximum number of bunnies that can run without frame drops is 300. After thorough debugging I discovered performance throttling happens due to multiple JsiRSXform.set calls happening in the same frame. This is basically how useRSXformBuffer hook works.

So, I'm thinking: shouldn't we batch our transforms buffer updates into one JSI call per frame per Atlas?

I've also tried creating 10000 bunnies, but only animate first 500. I wanted to confirm whether JsiRSXform.set calls throttling is the only reason. Unfortunately, this is also slow and animates at around 5 FPS. Presumably, due to the following code in JsiSkCanvas.h:


    std::vector<SkRSXform> xforms;
    int xformsSize = static_cast<int>(transforms.size(runtime));
    xforms.reserve(xformsSize);
    for (int i = 0; i < xformsSize; i++) {
      auto xform = JsiSkRSXform::fromValue(
          runtime, transforms.getValueAtIndex(runtime, i).asObject(runtime));
      xforms.push_back(*xform.get());
    }

    std::vector<SkRect> skRects;
    int rectsSize = static_cast<int>(rects.size(runtime));
    skRects.reserve(rectsSize);
    for (int i = 0; i < rectsSize; i++) {
      auto rect = JsiSkRect::fromValue(
          runtime, rects.getValueAtIndex(runtime, i).asObject(runtime));
      skRects.push_back(*rect.get());
    }

Version

1.2.3

Steps to reproduce

Run example code provided on a lower-end Android smartphone, under $100-150.

Snack, code example, screenshot, or link to a repository

Here is the code:

import {Canvas, Atlas, Fill, Text, rect, useRSXformBuffer, useImage, useFont} from '@shopify/react-native-skia'
import {runOnJS, useSharedValue, withRepeat, withTiming} from 'react-native-reanimated'
import {useEffect, useState} from 'react'
import {Dimensions} from 'react-native'
import {GestureDetector, Gesture} from 'react-native-gesture-handler'

const bunnyImage = require('../assets/bunnies.png')
const bunnyFont = require('../assets/fonts/SpaceMono-Regular.ttf')
const window = Dimensions.get('window')
const gravity = 0.5
const size = { width: 30, height: 45 }

export default () => {

  const [count, setCount] = useState(10000)
  const image = useImage(bunnyImage)
  const font = useFont(bunnyFont, 12);
  const label = useSharedValue<string>('0')
  const ticks = useSharedValue(0)

  const tap = Gesture.Tap().onStart(() => {
    runOnJS(setCount)(count + 100)
  })

  useEffect(() => {
    ticks.value = withRepeat(withTiming(1, { duration: 500 }), -1, true)
  }, [])

  const sprites = new Array(count).fill(0).map((_, i) => rect((i % 5) * size.width, 0, size.width, size.height))

  const transforms = useRSXformBuffer(count, (val, i) => {
    'worklet'

    const tickerUsage = ticks

    if (i === 0) label.value = 'Bunnies: ' + count

    // @ts-ignore
    const bunnies = global['bunnies'] || (global['bunnies'] = [])
    const bunny = bunnies[i] || (bunnies[i] = {
      x: 0,
      y: 0,
      speedX: Math.random() * 2 - 1,
      speedY: Math.random() * 2 - 1,
    })

    const translateX = bunny.x - size.width / 2
    const translateY = bunny.y - size.height

    bunny.x += bunny.speedX
    bunny.y += bunny.speedY
    bunny.speedY += gravity

    if (bunny.x < size.width / 2) {
      bunny.x = size.width / 2
      bunny.speedX *= -1
    } else if (bunny.x > window.width - size.width / 2) {
      bunny.x = window.width - size.width / 2
      bunny.speedX *= -1
    }

    if (bunny.y < 0) {
      bunny.y = 0
      bunny.speedY *= -1
    } else if (bunny.y > window.height + size.height) {
      bunny.speedY *= -0.85
      bunny.y = window.height + size.height
      if (Math.random() > 0.5) {
        bunny.speedY -= Math.random() * 6
      }
    }

    // if (i < 500)
    val.set(1, 0, translateX, translateY)

  })

  return (
    <GestureDetector gesture={tap}>
      <Canvas style={{ flex: 1 }}>
        <Atlas image={image} sprites={sprites} transforms={transforms} />
        <Text x={30} y={60} text={label} font={font} />
      </Canvas>
    </GestureDetector>
  )

}

bunnies

sergeymorkovkin commented 2 months ago

Likely, I need to implement frame pacing.

wcandillon commented 2 months ago

@sergeymorkovkin I hadn't the time to process the message above. I just wanted to write a quick note that I did build a prototype that implements frame pacing using that exact SDK. Back then we thought this would be the key to a fast first time to frame and sync the frames with native views. I didn't yield any benefits for us. I thought I would mention it.

wcandillon commented 2 months ago

Thank you for providing us with a standalone example. Would you mind providing us (even privately) with bunnies.png? That would allow us to test the example.

If you believe that indeed useRSXformBuffer is invoked more than once per frame. This is a bug. Did you try these examples in Release mode? On Android, Hermes in Debug mode is exceptionally slow.

sergeymorkovkin commented 1 month ago

Hi @wcandillon,

Responding to both of your messages:

  1. I'm now trying to implement Frame Pacing with a WebGL-based bunnymark, and this might give us some more information on the context.

  2. I understand that you've implemented Frame Pacing for RNSkia, and it didn't work well. This is definitely another valuable observation. Not sure how to explain this yet.

  3. An image for bunnies.png is in my issue initial message - find it at the very bottom. I tried downloading it, and it's unchanged in format/size - should work well.

  4. I don't say useRSXformBuffer is invoked more than once per frame. I say that JsiRSXform.set is repeated in a loop, inside useRSXformBuffer. Every call to JsiRSXform.set has JSI costs. On a small counts it can be neglected, but on the hundreds of bunnies we have hundreds of calls, which might need to be batched.

  5. Important. As I've already mentioned, I could not confirm that JsiRSXform.set calls is the only bottleneck. Evidently, it's not.

  6. We might compare react-native-skia architecture to the expo-gl - might get additional information from there.

Let's, maybe get on a call and I'll share my observations in-detail? Which messenger works for you best?