Pokemon Cards with Skia – Shader Optimisations

in this part we will focus no how we can optimse the shader component and the process of animating the shader.

CONTENTS:

Full Video Play button

▶ Full Video
Reel Play button

▶ Reel

📟 Pokédex GIF Gallery

Click any card to open the file.

Pikachu
#001 Pikachu
Bulbasaur
#002 Bulbasaur
Jolteon
#003 Jolteon
Charizard Big
#004 Charizard Big
Pikachu
#005 Pikachu
Chardizard
#006 Chardizard
Pikachu
#007 Pikachu
Squirtle
#008 Squirtle
Marill
#009 Marill
Mew
#010 Mew
Mew Controls
#011 Mew Controls
🟡 ???
Coming soon

Shader optimisation

One of the first things that I have noticed in other examples is using state to compute time.

const [time, setTime] = React.useState(0);
const requestRef = React.useRef<number>();
// Animation frame callback
const animate = useCallback(() => {
  setTime(t => t + 0.016);
  requestRef.current = requestAnimationFrame(animate);
}, []);

This forces the component to rerender. Instead use this hook:

useClock

Use clock returns

SharedValue<number>

from react-native-reanimated that can be easily used in other animations.

This way your shader component can limit render to the minimum and focus on the actual <Shader /> component renders the compiled Skia shader code.

Remember Skia can render shared values by reference. It renders the shader code as also a reference. By removing any state you basically keep this component mounted one time and all other animations are performed by referenced variables and referenced compiled shader.

This is the reason why the shader is lifted into the canvas component and compiled there and only after that passed into the shader rendering background and referenced from current value:

const shaderEffect = props.shaderEffectRef.current;

and passed here:

<Shader source={shaderEffect} uniforms={uniforms} />

Do not use requestAnimationFrame

DO NOT use requestAnimationFrame runs on js thread. If you are using react-native-skia you should avoid calling anything from the js thread. Skia runs on ui thread so when possible use:

  • useSharedValue
  • useDerivedValue
  • useFrameCallback

Shader uniforms are derived, not rebuilt through React state

This is another optimization that will keep the shader canvas mounted and work on references. Since in our case it is the time that changes we have a memoized resolution and we use a derived value for the time.

const resolution = useMemo<[number, number]>(
   () => [props.width, props.height],
   [props.width, props.height],
 );

 const uniforms = useDerivedValue(
   () => ({
     iTime: props.time.value,
     iResolution: resolution,
   }),
   [props.time, resolution],
 );

The uniforms will not recompute. Since this is a useDerivedValue it will return a:

DerivedValue<Value = unknown>

Which in practice is a:

Readonly<Omit<SharedValue<Value>, 'set'>>

Again. This will continue to live as a reference keeping our mount and unmount to minimum and the shader code operating on references.

  • efficient time-driven shader animation
  • no React churn per frame
  • better compatibility with Skia animation flow

CODE COMPARE:

https://github.com/jerinjohnk/RNShaderCard/blob/main/src/card/LuffyCanvas.tsx

Mentioning the optimisations we were talking about before this code:

import React, {useCallback} from 'react';
import {
 Canvas,
 useImage,
 Fill,
 Shader,
 Skia,
 Image,
} from '@shopify/react-native-skia';
import {KaleidoscopeShader} from '../shader/KaleidoscopeShader';

interface LuffyCanvasProps {
 width: number;
 height: number;
}

export function LuffyCanvas({width, height}: LuffyCanvasProps) {
 const [time, setTime] = React.useState(0);
 const requestRef = React.useRef<number>();

 // Animation frame callback
 const animate = useCallback(() => {
   setTime(t => t + 0.016);
   requestRef.current = requestAnimationFrame(animate);
 }, []);

 // Setup and cleanup animation frame
 React.useEffect(() => {
   requestRef.current = requestAnimationFrame(animate);
   return () => {
     if (requestRef.current) {
       cancelAnimationFrame(requestRef.current);
     }
   };
 }, [animate]);

 const luffy = useImage(require('../../assets/luffy.png'));

 if (!luffy) {
   return null;
 }

 const shaderEffect = Skia.RuntimeEffect.Make(KaleidoscopeShader);

 if (!shaderEffect) {
   return null;
 }

 return (
   <Canvas style={{width, height}}>
     <Fill>
       <Shader
         source={shaderEffect}
         uniforms={{
           iTime: time,
           iResolution: [width, height],
         }}
       />
     </Fill>
     <Image image={luffy} height={height} width={width} fit="contain" />
   </Canvas>
 );
}

//Could be rewritten into something like this:
import React, { useMemo } from "react";
import {
 Canvas,
 Fill,
 Shader,
 Skia,
 Image,
 useImage,
 useClock,
} from "@shopify/react-native-skia";
import { useDerivedValue } from "react-native-reanimated";
import { KaleidoscopeShader } from "../shader/KaleidoscopeShader";
interface LuffyCanvasProps {
 width: number;
 height: number;
}

export function LuffyCanvas({ width, height }: LuffyCanvasProps) {
 const luffy = useImage(require("../../assets/luffy.png"));
 const shaderEffect = useMemo(() => {
   return Skia.RuntimeEffect.Make(KaleidoscopeShader);
 }, []);
 const clock = useClock();
 const uniforms = useDerivedValue(() => {
   return {
     // useClock() is milliseconds, convert to seconds if your shader expects GLSL-style time
     iTime: clock.value / 1000,
     iResolution: [width, height],
   };
 }, [clock, width, height]);

 if (!luffy || !shaderEffect) {
   return null;
 }

 return (
   <Canvas style={{ width, height }}>
     <Fill>
       <Shader source={shaderEffect} uniforms={uniforms} />
     </Fill>
     <Image
       image={luffy}
       x={0}
       y={0}
       width={width}
       height={height}
       fit="contain"
     />
   </Canvas>
 );
}

Our example uses some more optimisation but I just wanted to show how you can use reference and Skia useClock to avoid using the js thread.

Shader guard

We used that previously. If there is no shader set just render a null.

if (shaderEffect == null) {
   return null;
}

This will not unmount the component and cause the reflow by keeping the virtual tree structure the same as before.

Custom background memo compare

This is a great example that shows why using derived and shared values is better for animation heavy components in react. The sader component only mounts and unmounts when you change the shader.

In practice this memo:

export const Backgrdound = memo(
 BackgrdoundComponent,
 (prev, next) =>
   prev.width === next.width &&
   prev.height === next.height &&
   prev.shaderEffectRef === next.shaderEffectRef &&
   prev.time === next.time,
);

and this memo (time removed):

export const Backgrdound = memo(
 BackgrdoundComponent,
 (prev, next) =>
   prev.width === next.width &&
   prev.height === next.height &&
   prev.shaderEffectRef === next.shaderEffectRef &&
);

Are functionally the same. React will not trigger a re-render on time change since this is a shared value and it will occupy the same space in js memory (that is how comparing objects in here works) not a single re-render will be triggered by the time from useClock method in parent canvas component.

This means that Skia Shader will continue to animate without touching the virtual tree just on shared values (references) permanently on the ui thread.

  • keeps shader subtree stable
  • avoids unnecessary React work around Skia shader node setup

Toggable scene composition

The layer system supports independent visibility. Instead of one giant render block, rendering is split into focused visual units. This allows the renderer to omit certain batches of work by deciding what to do and early returning null nodes like we have mentioned before. The order of the nodes is important because they are painted one over another.

  • shader background
  • hologram
  • gloss
  • image/background
  • outline holo
  • hologram mask usage
  • holo background

This way of composing your canvas is very useful when you have a canvas made of layers that you know well at the time you write your code.

  • avoids paying for the full visual stack all the time
  • scales better across lower-end devices
  • gives granular control over GPU/CPU load
  • memoize pieces independently
  • gate features independently
  • profile effect-specific costs

CANVAS AND SHADER SUMMARY

Canvas and shader optimisations. What do these optimizations improve in practice?

  • resistance to parent rerenders
  • Skia layer stability
  • shader animation efficiency
  • motion-driven visual smoothness
  • reduced prop identity churn
  • reduced inactive layer cost
  • cleaner performance monitoring isolation

🤝 Sponsors

WORKDEI

Generalne wykonawstwo inwestycji
Podwykonawstwo budowlane
Usługi budowlane Poznań
Nadzór budowlany
MILWICZ ARCHITEKCI

Gotowy projekt domu
Architekt Poznań
Projekt domu na zamówienie
Nowoczesna stodoła
DRL-CLINIC

Dentysta Kraków
Ortodonta Kraków
Implanty Kraków
Metamorfoza uśmiechu
MAXDENT

Leczenie laserowe Wrocław
Leczenie pod mikroskopem
Ortodonta Wrocław
Implanty Wrocław

💛 Thanks to our partners for supporting this project

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top