RN Skia 써보기

Keonwoo Kim·2025년 1월 13일
0

삽질

목록 보기
2/2

뭔가 타일링하는 기능을 구현해 봤습니다

초기 버전

import { View, useWindowDimensions } from "react-native";

import React, {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import {
  Canvas,
  Group,
  Image,
  Mask,
  Rect,
  SkImage,
  Transforms3d,
  useImage,
} from "@shopify/react-native-skia";
import {
  useDerivedValue,
  useSharedValue,
  withRepeat,
  withTiming,
} from "react-native-reanimated";

const App = () => {
  const window = useWindowDimensions();
  const width = window.width;
  const height = window.height;

  const GRID_SIZE = 16;

  const image = useImage(require("../assets/images/Basic_Furniture.png"));

  const y = useSharedValue(0);

  useEffect(() => {
    y.value = withRepeat(withTiming(height, { duration: 2000 }), -100, true);
  }, []);

  const transform = useDerivedValue(() => {
    return [{ translateY: y.value }];
  });

  const xys = useMemo(() => {
    return Array.from({ length: 50 }, (_, i) => {
      const x = Math.round(Math.random() * 10);
      const y = Math.round(Math.random() * 20);
      return { x, y, i };
    });
  }, []);
  console.log("r");

  return (
    <Canvas style={{ width, height }}>
      <Group transform={transform}>
        {image && (
          <>
            <Sprite
              image={image}
              x={GRID_SIZE * 3}
              y={GRID_SIZE * 2}
              width={GRID_SIZE}
              height={GRID_SIZE}
              transform={[{ scale: 2 }]}
            />
            {xys.map(({ x, y, i }) => (
              <AnimatedSprite
                key={i}
                image={image}
                x={GRID_SIZE * 4}
                y={GRID_SIZE * 2}
                width={GRID_SIZE}
                height={GRID_SIZE}
                frames={4}
                cols={4}
                transform={[
                  { scale: 2 },
                  { translateX: GRID_SIZE * x },
                  { translateY: GRID_SIZE * y },
                ]}
              />
            ))}
          </>
        )}
      </Group>
    </Canvas>
  );
};

function Sprite({
  image,
  x,
  y,
  width,
  height,
  transform = [],
}: {
  image: SkImage;
  x: number;
  y: number;
  width: number;
  height: number;
  transform?: Transforms3d;
}) {
  return (
    <Group transform={[...transform, { translateX: -x }, { translateY: -y }]}>
      <Mask mask={<Rect x={x} y={y} width={width} height={height} />}>
        <Image
          image={image}
          x={0}
          y={0}
          width={image.width()}
          height={image.height()}
        />
      </Mask>
    </Group>
  );
}

function AnimatedSprite({
  image,
  x,
  y,
  width,
  height,
  frames,
  frameDuration = 250,
  cols,
  transform = [],
}: {
  image: SkImage;
  x: number;
  y: number;
  width: number;
  height: number;
  frames: number;
  cols: number;
  frameDuration?: number;
  transform?: Transforms3d;
}) {
  const [frameIndex, setFrameIndex] = useState(0);

  let start = useRef<number | undefined>(undefined);
  let lastFrameIndex = useRef<number | undefined>(undefined);
  useEffect(() => {
    const step = (timestamp: number) => {
      if (start.current === undefined) {
        start.current = timestamp;
      }
      const frameIndex =
        Math.floor((timestamp - start.current) / frameDuration) % frames;
      if (
        lastFrameIndex.current === undefined ||
        lastFrameIndex.current !== frameIndex
      ) {
        setFrameIndex(frameIndex);
        lastFrameIndex.current = frameIndex;
      }
      requestAnimationFrame(step);
    };

    const raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }, [frameDuration]);

  const fx = x + (frameIndex % cols) * width;
  const fy = y + Math.floor(frameIndex / cols) * height;

  return (
    <Group transform={[...transform, { translateX: -fx }, { translateY: -fy }]}>
      <Mask mask={<Rect x={fx} y={fy} width={width} height={height} />}>
        <Image
          image={image}
          x={0}
          y={0}
          width={image.width()}
          height={image.height()}
        />
      </Mask>
    </Group>
  );
}

export default function () {
  return (
    <View className="bg-red-300 h-full w-full">
      <App />
    </View>
  );
}

-> 51개: 10 FPS...

셰이더를 이용하여 개량

import { View, useWindowDimensions } from "react-native";

import React, { useEffect, useMemo, useRef, useState } from "react";
import {
  Canvas,
  Fill,
  Group,
  Image,
  ImageShader,
  Mask,
  Rect,
  Shader,
  SkImage,
  Skia,
  Transforms3d,
  useImage,
} from "@shopify/react-native-skia";
import {
  useDerivedValue,
  useSharedValue,
  withRepeat,
  withTiming,
} from "react-native-reanimated";

const MAX_OBJECTS = 256;
const TOTAL_OBJECTS = 1280;

const source = Skia.RuntimeEffect.Make(`
uniform shader texture;
uniform half2 canvasSize;
uniform half2 textureSize;
uniform int gridSize;
uniform int objectCount;
uniform int4 objects[${MAX_OBJECTS}];

int mod(int a, int b) {
  return a - b * (a / b);
}

half4 blend(half4 a, half4 b) {
  return a + b * (1 - a.a);
}

half4 main(float2 pos) {
  if (pos.x >= canvasSize.x || pos.y >= canvasSize.y) {
    return float4(0, 0, 0, 0);
  }

  short2 ipos = short2(pos);
  short2 gpos = ipos / gridSize;
  half4 frag = float4(0, 0, 0, 0);

  for (int i = 0; i < ${MAX_OBJECTS}; i++) {
    if (i >= objectCount) break;
    int4 o = objects[i];

    if (o.x > gpos.x || (o.x == gpos.x && o.y > gpos.y)) break;
    if (gpos.x == o.x && gpos.y == o.y) {
      short cols = short(textureSize.x) / gridSize;
      short tx = mod(o.z, cols);
      short ty = o.z / cols;
      short dx = mod(ipos.x, gridSize);
      short dy = mod(ipos.y, gridSize);
      half2 xy = half2(
        gridSize * tx + dx,
        gridSize * ty + dy
      );
      frag = blend(frag, texture.eval(xy));
      // if (frag.a == 1) break;
    }
  }

  return frag;
}`)!;

const FOUR_ZEROS = Object.freeze([0, 0, 0, 0]);

const App = () => {
  const window = useWindowDimensions();
  const scale = 2.5;
  const width = Math.ceil(window.width / scale);
  const height = Math.ceil(window.height / scale);

  const GRID_SIZE = 16;

  const image = useImage(require("../assets/images/Basic_Furniture.png"));

  const y = useSharedValue(0);
  useEffect(() => {
    y.value = 0;
    y.value = withRepeat(withTiming(500, { duration: 500 }), -1, true);
  }, []);
  const transform = useDerivedValue(() => {
    return [{ translateY: y.value }];
  });

  const objects = useMemo(() => {
    if (!image) return null;

    const TX = 1;
    const TY = Math.ceil(image.width() / GRID_SIZE);
    const MAX_TX = TY;
    const MAX_TY = Math.ceil(image.height() / GRID_SIZE);

    const result = Array.from({ length: TOTAL_OBJECTS }, () => {
      const tx = Math.floor(Math.random() * MAX_TX);
      const ty = Math.floor(Math.random() * MAX_TY);
      const zIndex = Math.floor(Math.random() * 1024);

      const x = Math.floor(Math.random() * 36) - 10;
      const y = Math.floor(Math.random() * 50) - 10;

      return [x, y, TX * tx + TY * ty, zIndex];
    }).sort((a, b) => {
      if (a[0] - b[0] !== 0) return a[0] - b[0];
      if (a[1] - b[1] !== 0) return a[1] - b[1];
      return b[3] - a[3];
    });

    return result.filter(
      ([x, y]) =>
        x >= 0 && x < width / GRID_SIZE && y >= 0 && y < height / GRID_SIZE,
    );
  }, [image, width, height]);

  const objectsUniform = useMemo(() => {
    if (!objects) return [];

    return Object.freeze(
      Array.from({ length: MAX_OBJECTS }, (_, i) => {
        const [x, y, assetIndexInTex, zIndex] = objects[i] || FOUR_ZEROS;
        return [x, y, assetIndexInTex, zIndex];
      }),
    ).slice(0, MAX_OBJECTS);
  }, [objects]);

  const skiaObjects = useMemo(() => {
    if (!image) return null;

    return (
      <Shader
        source={source}
        uniforms={{
          canvasSize: [width, height],
          textureSize: [image.width(), image.height()],
          gridSize: GRID_SIZE,
          objectCount: objects?.length ?? 0,
          objects: objectsUniform,
        }}
      >
        <ImageShader
          image={image}
          rect={{
            x: 0,
            y: 0,
            width: image.width(),
            height: image.height(),
          }}
        />
      </Shader>
    );
  }, [image]);

  if (!image) return null;

  return (
    <View
      style={{
        backgroundColor: "red",
        width: width * scale,
        height: height * scale,
      }}
    >
      <Canvas style={{ width: width * scale, height: height * scale }}>
        <Group transform={transform}>
          <Fill clip={{ x: 0, y: 0, width, height }} transform={[{ scale }]}>
            {skiaObjects}
          </Fill>
        </Group>
      </Canvas>
    </View>
  );
};

export default function TabOneScreen() {
  return <App />;
}

-> 1280개 (화면에는 최대 256개): 60FPS
-> 2560개 (화면에는 최대 512개): 52FPS -> 42FPS (throttling?)

0개의 댓글