Pokemon Cards with Skia – Sensors and General Optimisations

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

CONTENTS:

Here we will talk about how we can optimize the sensor’s movement. It can be useful for everyone that plans adding motion sensors to their applications. Sensors behave slightly differently between Android and iOS. This is the reason why I have added a custom override for the transform. In the case of android the card was tilted backward.

const totalRotateX =
     params.gestureRotateX.value +
     params.sensorRotateX.value +
     TRANSFORM_OVERRIDE_X;

const totalRotateY =
     params.gestureRotateY.value +
     params.sensorRotateY.value +
     TRANSFORM_OVERRIDE_Y;

If your device is giving you a different feedback than mine please change these variables to your custom values.

In case of the sensor pipeline we are going to use a few optimisations that are going to reduce the number of renders and transformation ot a necessary minimum. We are also going to smooth out the animations so they will look better.

  • limited update frequency
  • dead zone
  • clamping
  • smoothing
  • epsilon thresholding
  • start and end timed smoothing

This will have an effect on our card gesture and motion movement in a way that transform events will fire only when the movement is noticeable.

  • reduces jitter and noise
  • minimizes unnecessary updates
  • produces more natural motion

Limited update frequency

This is the first thing that you should do. In android we can opt in to enable high sampling rate.

<uses-permission android:name="android.permission.HIGH_SAMPLING_RATE_SENSORS" />

On iOS this will be set by Core Motion CMMotionManager

motionManager.accelerometerUpdateInterval = 1.0 / 120.0;

Passing 16 will cause a ~60Hz update frequency. You can play with it.

setUpdateIntervalForType(SensorTypes.accelerometer, SENSOR_INTERVAL_MS);

If you are passing a hihger value will reduce the frequency. This value represent number of milliseconds that have to pass between sampling the motion.

Dead Zone filter

Dead zone is the first thing that we run inside the accelerometer subscription function:

const subscription = accelerometer.subscribe(({ x, y }) => {
     const filteredX = applyDeadZone(x, SENSOR_DEAD_ZONE);
     const filteredY = applyDeadZone(y, SENSOR_DEAD_ZONE);
}

Dead zone is one of the worklet methods. This way it does not wait to communicate between the js and ui thread.

export const applyDeadZone = (value: number, deadZone: number) => {
 "worklet";
 return Math.abs(value) < deadZone ? ZERO : value;
};

If the movement is not big enough it will not transform the style of the card. If the rotation is less than a dead zone it will be reduced to zero and produce the default style.

Clamping

Clamping is a technique that you can use in order to have a centralised control over the motion. As mentioned before it is very important to separate the render logic from every other calculation.

export const clamp = (value: number, min: number, max: number) => {
 "worklet";
 return Math.min(Math.max(value, min), max);
};

export const mapToAngle = (
 value: number,
 size: number,
 maxAngle: number,
 isReverse = false,
) => {
 "worklet";
 if (size <= ZERO) {
   return ZERO;
 }
 const progress = clamp(value / size, ZERO, 1);
 const angle = progress * (maxAngle * 2) - maxAngle;
 return isReverse ? -angle : angle;
};

Clamp with map to angle will limit the movement of the card to a controllable level. You can control the angle by passing it as a prop or setting a different value for DEFAULT_MAX_ANGLE

const maxAngle = params.props.maxAngle ?? DEFAULT_MAX_ANGLE;

Smoothing

Smoothing is a technique that also removes the jitter. It is visually pleasing but it also reduces sudden jumps in the motion sensor values. Sudden jumps mean that you will have to update the view in a more radical way. Smoothing can provide the same visual effect but with fewer updates. We apply it to rotate and translate.

const nextRotateX = smoothValue(
       params.sensorRotateY.value,
       targetRotateY,
       SENSOR_SMOOTHING,
     );

const nextTranslateX = smoothValue(
       params.sensorTranslateX.value,
       targetTranslateX,
       SENSOR_SMOOTHING,
     );

Instead of noisy input we get 20-30 updates per second.

Epsilon threshold

A similar technique to the dead zone is the epsilon threshold. It is like a dead zone during the motion. So you are moving your phone or making a gesture and you stop the movement a little. The card is already tilted. In order to remove the micro jitter we prevent passing to render pipeline updates that are less than a selected epsilon value. 

This is the last optimisation in our sensor pipeline.

if (shouldUpdateValue(
   params.sensorRotateX.value,
   nextRotateX,
   SENSOR_UPDATE_EPSILON)
) {
       params.sensorRotateX.value = nextRotateX;
}

This is the epsilon test:

export const shouldUpdateValue = (
 current: number,
 next: number,
 epsilon: number,
) => {
 "worklet";
 return Math.abs(current - next) > epsilon;
};

We check if the absolute value of the current and next is worth sending to the rendering pipeline.

Start and end timed smoothing

You can see this optimization especially at the end of the gesture. You can make a full gesture dragging the card to the maximum of the angle and then releasing it. You will notice that the animation will smooth out to the initial position. This is how our code will behave onFinalize gesture event.

.onFinalize(() => {
         params.gestureRotateX.value = withTiming(ZERO, TIMING_CONFIG);
         params.gestureRotateY.value = withTiming(ZERO, TIMING_CONFIG);
})

withTiming from react-native-reanimated is a worklet that will make this animation smooth back to the initial 0 value.

export const withTiming = function (
 toValue: AnimatableValue,
 userConfig?: TimingConfig,
 callback?: AnimationCallback
): Animation<TimingAnimation> {
 'worklet';
// animation code
}

GENERAL

I left this part as the last one because they are not specific to this project. We already mentioned some of the general optimisation bu they were useful in our case. A good example is cached style array composition inside react components. This is a native behaviour.

Constant and default value export

There is a very simple reason why we have moved all the common value definitions into a single file. Most js engines are very good at caching them. If you would keep them inside the function declaration or just inline them inside the component or function call for every single one the engine would perform an operation of space allocation and variable initialisation and then would have to deallocate it afterwards – total waste of memory and cpu cycles.

export const ZERO = 0;
export const ZERO_SHARED = {
 value: 0,
} as SharedValue<number>;
export const ZERO_GRADIENT_POINTS: GradientPoints = {
 start: { x: 0, y: 0 },
 end: { x: 0, y: 0 },
};
  • improve overall js code behaviour,
  • aggressive cached value reuse,
  1. Style and style variable caching

In our case we also have moved the gradient definition to a separate variable the same way we did with the variables.

export const HOLO_COLORS = [
 "#ff3b30",
 "#ff9500",
 "#ffcc00",
 "#4cd964",
 "#34aadc",
 "#5856d6",
 "#2e2d87",
] as Color[];

Our holo colors always occupy the same space in engines memory.

Style array outside component

This is a optimisation that is not used in our code because we use memoisation for styles that are dependant on width and height parameters but you should also try to combine your styles outside of component if possible.

const componentStyle = [style.first, style.second, styleSecondary.variant]

What we do here is use styles from two different stylesheets that are cached in memory of the component.

Although they are stored in device cache and the rendering engine has quick access to it, if we put them inside the component the array will be initialised and computed every time the component renders.

If you move the composition of the style outside the component it will occupy the same place in the memory. This will result in removing unnecessary array recomputation on every render.

🤝 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