Showing posts with label love2d. Show all posts
Showing posts with label love2d. Show all posts

Sunday, November 29, 2020

LÖVE on Windows 10 ARM64 Part 3: We're Going ARM64

LÖVE 12.0-development branch running in Windows 10 ARM64 under QEMU.

This is the moment of truth, and this is will be the last part of my LÖVE on Windows 10 ARM64 blog post series (part 1 here, part 2 here). In short, it's possible.

The long answer however, require various patches. Most of the patches went into external libraries that LÖVE needed. The timeline of these is simply by these recent commits, but an explanation each of them are as follows:

The first thing to do is to update SDL to 2.0.12, apply Megasource-specific patches, then apply this patch so it compiles under VS2019 because screw MSVC generating calls to memset and memcpy even when you specify /NODEFAULTLIB! Next is to update OpenAL-soft up to the recent commit for MSVC ARM64 support (see my previous blog post, I mentioned about OpenAL-soft there) then re-apply Megasource-specific patches.

The next library is bit tough and this is where most of my time spent. Ogg and Vorbis is the most annoying one that I start to suspect this is CMake bug (I use 3.19.1, the latest as of writing). I updated Ogg and Vorbis to 1.3.4 and 1.3.7 respectively, and for some reason I got error that reads "cannot open input file 'ogg.obj'" for "liblove" and "megatest" targets. Looking at the project configuration using Visual Studio shows that"ogg" is referenced twice in those both targets. I unfortunately went to last resort by using Megasource-provided CMake for both projects and the problem went away. To be honest, I have no idea why that happends, and I'm kind of sure that using Megasource-provided CMake may gives inferior performance because it uses generic, non-architecture-dependent code. Anyway it's solved so let's move on.

The last change is to Megasource CMakeLists.txt itself. There are various changes there that needs to be explained (I'll be using green-highlighted line number).

  • Line 20: Add variable for detecting ARM64 compilation. Currently, it only works for MSVC + Visual Studio targets but this is sufficient for my needs at the moment.
  • Line 155 and line 171: Unfortunately, as of CMake 3.19.1, their InstallRequiredSystemLibraries module doesn't support MSVC ARM64 and it will pick x64/AMD64 DLLs instead, so those lines will supress copying the MSVC redistributable libs when compiling for MSVC ARM64.
  • Line 242: SDL will try to load OpenGL32 in Windows first then trying other backends. This gives me bit puzzle when prototyping my patches because even setting LOVE_GRAPHICS_USE_OPENGLES=1, SDL_RENDER_DRIVER=opengles, and
    SDL_OPENGL_ES_DRIVER=1 environment variable has no effect, so I went into last resort and tell SDL not to compile the OpenGL backend instead. This is fine, LOVE will run using OpenGLES codepath using ANGLE.

    SDL fails to find OpenGL32.dll in Windows 10 ARM
  • Line 270 and line 332: LuaJIT doesn't support Windows 10 ARM64 yet, so Lua 5.1.5 bundled with the Megasource must be used. This is actually a performance impact but if even the LuaJIT interpreter can't compile (let alone the JIT compiler) then it's impossible to use LuaJIT there unless Mike adds support for it. This also increases fragmentaton because people who uses LÖVE are used to bitwise library provided by LuaJIT, but a possible fix for this is to bundle LuaJIT's LuaBitOp within Lua 5.1.5 or LOVE (when LuaJIT is not used).

After applying patches to Megasource, now patches in LÖVE are as follows, and mostly related to its buildsystem and dependencies instead:

First, tell LÖVE not to link to OpenGL. While Windows 10 SDK for ARM64 provides OpenGL headers, it doesn't include OpenGL library which cause link errors in later step. This is fine and there are no noticeable problems whatsover (even in Windows x64 builds) because LÖVE will use SDL to load OpenGL(ES) functions anyway.

The next is PhysFS, which is easy fix, and I have plan to report that later on. The last problem is dr_mp3 used in LOVE 12.0. dr_mp3 and dr_flac doesn't expect this compiler and platform combination so I have to report this problem to upstream for a proper fix (which for dr_flac, doesn't compile error so that one can wait). This is why this blog post is slightly delayed.

Afterwards, LÖVE will compile and you can install and push it to your Windows 10 ARM64 machine (or QEMU) and see LÖVE runs there. Currently I can't provide binaries at the moment because the automated GitHub Actions that's supposed to compile LÖVE binaries via artifacts is failing for some reason and quick search shows that it's OpenAL-soft to blame. So an update to prebuilt binaries will probably come in another blog post or edits in this blog post instead.

Speaking of ARM64, someone also managed to compile LÖVE for Apple Silicon


 ... which is related to this blog post title.

Monday, November 16, 2020

LÖVE on Windows 10 ARM64 Part 1: Compiling Dependencies

ARM64 laptops probably start getting its hand on like 2021 or 2022 (wild guess). As one of LÖVE development team, this makes me wonder if I can run LÖVE under Windows 10 ARM64, natively, because using x86 emulation is cheating and doesn't work anyway due to LÖVE dependency to OpenGL.

First thing I have to think is:

  1. Get Windows 10 ARM64 toolchain. This is easy. Visual Studio Installer provide this.
  2. Get QEMU.
  3. Get Windows 10 ARM64 image.

Fact: I have to go to my friend house and ask to use his WiFi to download those because I'm poor.

Quick testing the ARM64 toolchain by compiling my infamous HonokaMiku with CMake shows the ARM64 cross compiler works. Now before compiling LÖVE, these things comes to mind prior trying to compile it:

  1. LuaJIT doesn't support Windows/ARM64 combination.
  2. OpenGL is not supported. 

Point 1 is kinda bummer because even LuaJIT interpreter is waaaayyy faster than Lua 5.1.5, but I have no choice other than adding -DLOVE_JIT=0 CMake flags later on. Point 2 can be solved by using ANGLE. I have to "brute-force" GitHub Actions to get ANGLE compiled for ARM64, but this pays off and the actions build script can be extended to other architecture later as well.

When trying to compiling it, unfortunately SDL, PhysFS, Vorbis, and OpenAL-soft fails to compile. I've filled issue tracker for OpenAL-soft but SDL (and others) probably needs to wait longer as I also need to attend online classes (univ related), thus slowing down this experiment as whole. Known libs to compile are FreeType, GLSLang, zlib, and Ogg. Others are unknown as MSBuild prevents me compiling other dependencies.

For the VM part, I haven't getting myself a way to pass OOBE in Windows 10 ARM64 running under QEMU. Mind you, it's slower than dirt in i5-7200U. Blogpost about setting up the VM need to wait.

Also this is me trying to get LÖVE compile with Windows 10 ARM64 toolchain:

Lumine is underrated.

Sunday, June 30, 2019

MediaFoundation decoder for LOVE & decoding in-memory.

Windows 7 has its own COM-based API to aid decoding variety of audio formats, and if you concerned about patents (AAC, why), don’t worry as it’s a licensed decoder. This blog post is about how I wrote LOVE decoder that uses MediaFoundation.

Basics

First thing to know is how LOVE decoder class looks like.
class Decoder : public Object
{
public:
    static love::Type type;
    Decoder(Data *data, int bufferSize);
    virtual ~Decoder();
    static const int DEFAULT_BUFFER_SIZE = 16384;
    static const int DEFAULT_SAMPLE_RATE = 44100;
    static const int DEFAULT_CHANNELS = 2;
    static const int DEFAULT_BIT_DEPTH = 16;
    virtual Decoder *clone() = 0;
    virtual int decode() = 0;
    virtual int getSize() const;
    virtual void *getBuffer() const;
    virtual bool seek(double s) = 0;
    virtual bool rewind() = 0;
    virtual bool isSeekable() = 0;
    virtual bool isFinished();
    virtual int getChannelCount() const = 0;
    virtual int getBitDepth() const = 0;
    virtual int getSampleRate() const;
    virtual double getDuration() = 0;

protected:
    StrongRef<Data> data;
    int bufferSize;
    int sampleRate;
    void *buffer;
    bool eof;
};
Anything that ends with = 0; means pure virtual method which we must implement in our derived class. Now let’s derive the Decoder class.

MFDecoder Class

class MFDecoder: public Decoder
{
public:
    MFDecoder(Data *data, int bufferSize);
    virtual ~MFDecoder();

    static bool accepts(const std::string &ext);
    static void quit();
    Decoder *clone();
    int decode();
    bool seek(double s);
    bool rewind();
    bool isSeekable();
    int getChannelCount() const;
    int getBitDepth() const;
    double getDuration();

private:
    static bool initialize();
    static void *initData;
    // non-exposed datatype to prevent cluttering LOVE
    // includes with Windows.h
    void *mfData;
    // channel count
    int channels;
    // temporary buffer
    std::vector<char> tempBuffer;
    // amount of temporary PCM buffer
    int tempBufferCount;
    // byte depth
    int byteDepth;
    // duration
    double duration;
};
There are few points that must be noted here.
  • MFDecoder contstructor receives LOVE Data object, which is data located in block of memory, and the desired buffer size.
  • MediaFoundation API can return any number of samples so we use temporary buffer to contain leftovers after decoding.
  • You may notice that mfData is void*. The reason to do this is that there’s problem compiling LOVE if Windows.h is included BEFORE the keyboard module. We also don’t want to clutter the includes with Windows-specific includes and drag down the compilation time.
  • The tempBuffer is std::vector. Yes this is intentional so we don’t have to manage the allocated memory and take advantage of RAII. It also helps in case MediaFoundation returns data bigger than provided buffer by simply reallocating bigger temporary buffer.
  • Then there’s initData member. This is set at initialize function above it, which is called when new MediaFoundation decoder is created or if the compatible extensions are being checked.

Problems

Now there are some problems.
  • MediaFoundation doesn’t officially support loading media from memory.
    MediaFoundation assume you have the media file reside somewhere in local filesystem or in network, probably due to their DRM nature. There’s this blog post, but it only works on Windows 8 and using C++/CLI which has its own 2 problems:
    1. We can’t use C++/CLI when compiling LOVE, that would reduce our compilation times and increase the bloat which we want to try to minimize as possible.
    2. My target is to make the decoder available for Windows 7 and later. IRandomAccessStream and the function it uses are Windows 8 and later.
    Then I found out there’s MFCreateMFByteStreamOnStream which accepts IStream interface. IStream interface is available since Windows 2000, which then SHCreateMemStream can be used to create one.
  • The functions that I use requires linking to Mfplat.dll and Mfreadwrite.dll.
    The latter is only available in Windows 7. Since I want to make sure it runs in Windows Vista too (without the MediaFoundation decoding capabilities of course), I have to dynamically load it, hence the MFDecoder::initialize() static function.
  • Once IMFByteStream is created, you have to set the MIME.
    It’s done by casting the IMFByteStream to IMFAttributes and set the MIME. Unfortunately, as of LOVE commit ccf9e63, the decoder constructor no longer receives the audio extensions, so we have to test for every possible supported MIME types. Fortunately, Microsoft gives us list of supported media formats in their documentation, so I just get the MIME string from IIS MIME Types.
After those problems is resolved, it’s only matter of setting the properties of the IMFSourceReader like creating decoder which outputs PCM.

Seeking

Yes, the IMFSourceReader supports seeking, but there’s no guarantee that it will be accurate. The function you’re looking for is IMFSourceReader::SetCurrentPosition which accepts time as 100-nanoseconds (second to 100-nanosecond is multiply by 1e+7).

Another Problem: GUID

I’m getting linker errors when compiling LOVE with the MediaFoundation decoder as it complains about unresolved GUID. I also don’t want to link to any of MediaFoundation DLLs so it’s binary compatible with Windows Vista (XP support is dropped as of LOVE 11.0). Temporary workaround to fix this is to copy the GUID declaration in header files to const GUID variables.

Aftermath

After I get everything running, now I have LOVE build which can loads AAC and WMA using MediaFoundation, how good is that, huh? You can check the full source code in here. The respective header lies in same directory as the C++ file.

Now you may ask, what about MinGW? Well, unfortunately LOVE doesn’t support being compiled under MinGW in the first place, so compiling LOVE under Windows is only supported using MSVC compiler.

And if anyone wants to decode audio from memory using MediaFoundation, then this blog post is what he/she’s looking for.

Post is written in Markdown first then converted to HTML lol.

Tuesday, April 30, 2019

Fixing black border in your game image with ImageMagick.

Star with unintended black border
What's with that black around the star? That's because the image has "transparent black" (or in CSS notation, rgba(0, 0, 0, 0)) for all the fully transarent image and white with alpha otherwise. This is not a problem for image editing software like Photoshop but this is a problem for OpenGL especially if you use linear interpolation to resize your image.

What actually happend? If linear interpolation is enabled, the GPU will sample around the white and the "transparent black", thus will result in gray color with alpha around 0.5. This is not what you actually want as this can give your image an unintended black border, which may or may not bad for your game.

A solution for this is to modify your image to have "transparent white" instead.  Based on this answer, assume you have ImageMagick version 7 or later, I come up with this command:

magick convert input.png -channel RGB xc:"#ffffff" -clut output.png

And here's the result.
Star without black border around it.

Now what happends here is that we replace all color channel to 255 (thus result in white), but we keep the alpha values intact. The GPU then will see all the colors as white but only varying in alpha, so it will only interpolate the alpha because all the colors are white.

And if you plan to pass your image to zopflipng, make sure not to pass --lossy_transparent as that option changes all completely transparent pixel to "black transparent" again, which is the source of the problem.

UPDATE: ImageMagick command above won't work for images with various colors. I forked alpha-bleeding program which uses LodePNG to ease MSVC compilation which can be found here: https://github.com/MikuAuahDark/alpha-bleeding.

Friday, January 25, 2019

Re-Implementing Live2D runtime in LÖVE: Performance Optimization

Please see my previous blog post for more information. Do you think I'm really satisfied with 1.2ms performance? No. I think I can do more. Note that when I wrote a time measurement, that means it's time taken to update Kasumi casual summer model (shown below).


Code Optimization

I see that after my previous blog post, there's many optimization that can be done. I started it by reducing the temporary table creation. Instead of creating new table at function body, I created new table at file scope and reuse that table over and over. In the motion manager code, I used this variant of algorithm to remove motion data when necessary. I also localize function that's called every frame, mostly functions from math namespace like math.min, math.floor, and math.max. I also do cache more variable if that variable is used multiple times to reduce table lookup overhead.

Although the optimization I listed above doesn't really save significant amount of time when JIT is used, it's somewhat significant optimization for non-JIT codepath. Next optimization is by converting hair physics code to use FFI datatype for JIT codepath, and class otherwise. Testing gives better performance, 1.17ms. Not much but it's better than nothing.

Problem arise, when I inspect the verbose trace compiler output, I noticed lots of "NYI: register coalescing too complex" trace abort in the curved surface deformer algorithm, which indicate I'm using too many local variable there. At first this was bit hard to solve, but I managed to optimize it by analyzing the interpolation calculation done by curved surface deformer algorithm. Then it solve the trace aborts entirely. Testing gives slightly better performance, 1.15ms.

Rendering Optimization

The last optimization I done is the Mesh optimization. Since I copied Live2LOVE Mesh rendering codepath as-is, it's actually uploading lots of duplicate data to the GPU, duplicating the vertices based on vertex map manually in CPU side because I thought the vertex map can change. This can be very slow for the non-JIT codepath because the amount of data needs to be send in Mesh:setVertices can be too much. As a reference, before doing this optimization, the non-JIT codepath (LuaJIT interpreter) took 6ms.

After having better overview how Live2D rendering works, I'm safe to assume vertex map won't ever change, so I start by reducing amount of vertices that needs to be uploaded to GPU and send the vertex map. This gives more significant performance boost in CPU-side actually. The JIT codepath now runs at 1.05ms, it's very very close to Live2LOVE 1ms. Interpreter (LuaJIT) took 4ms, yes 4ms to update the model. Unfortunately, vanilla Lua 5.1 took as long as 12ms to update the model.

The non-JIT codepath is forced to use table variant of Mesh:setVertices because the overhead of FFI is higher than the benefit of using Data variant. Also the non-JIT codepath can't assume FFI is available at all. LuaJIT can be compiled without FFI support (but who wants to do this?) or it maybe run in vanilla Lua 5.1. One of my goal for this project is to provide maximum compatibility with Lua 5.1 too, despite LÖVE is compiled using LuaJIT by default.

Experimental Rendering Codepath

Unfortunately I have to throw away the mesh batching technique I mentioned in my previous blog post. This mesh batching technique causes very significant slowdown both in JIT and non-JIT codepath with very little performance improvement in GPU, so I decide to abandon this and use the old approach of updating models, drawing Mesh one by one. You can see at screenshot below that the model took 166 drawcall

and additional drawcall caused by IMGUI.

Wednesday, January 16, 2019

Re-Implementing Live2D runtime in LÖVE


 (video above shows my implementation in action using LÖVE framework)

Live2D is a nice thing, the fluid character movement gives additional touch to the game which uses it. For my personal use, however it has annoying limitation: Lua is not officially supported, especially LÖVE. Well, there's 2 ways to overcome this.

Writing external Lua C module

This is probably the simplest way (but not that easy). Link with Live2D C++ runtime library files, add code which interact with Lua (and LÖVE), then you got Live2LOVE. This module is actually very fast, considering the Lua to C API overhead, and works by letting Live2D to do the model transformation and LÖVE to do the model rendering. Since it uses official Live2D runtime, it has these limitations:
  • Must link with OpenGL. This is not a problem since LÖVE uses OpenGL already.
  • VS2017 is not supported (you have to use Cubism 3 for that). However it supports down to VS2010, but LÖVE requires VS2013, so this is not really a problem unless you compile LÖVE to use VS2017 runtime.
  • MinGW/Cygwin compilation is not supported. Not really a problem since compiling LÖVE in Windows using MinGW/Cygwin itself is not supported.
  • Linux and macOS is not supported. This is the real problem. Not all people use Windows to run LÖVE.
So another idea that comes to my mind is:

Re-Implement Live2D Cubism 2 Runtime

In Lua, because why not. This is actually somewhat time-consuming process and took me more than 3 weeks to have model rendering working as intended. My additional goal for this is to have Live2LOVE-compatible interface too, so switching between implementation is simply changing the "require" module. From now on, I'll refer Live2D Runtime as Live2D Cubism 2 Runtime.

I start by downloading Live2D Runtime for WebGL (Javascript) and beautify the code (since it's in .min.js file). As expected, the function method names are obfuscated. So, I unpacked Android version of Live2D C++ Runtime and deobfuscate the method name by matching the arguments from Live2D Runtime C++ header files and with help of IDA pseudocode decompiler to compare the implementation with the Javascript ones. This whole process took 2 weeks.

Then I start by writing the Lua equivalent code based on Javascript Live2D Runtime. This is the easiest to do since Javascript is also dynamically typed. Carefully translating 0-based indexing code to 1-based. Then fixing bugs, writing Live2LOVE-compatible interface so I can use existing Live2D viewer code that using Live2LOVE, and testing.

After a week, I got model rendering, motion, and expression working. Using existing code from my LÖVE-based Live2D model viewer to use my own implementation instead of Live2LOVE. Then the next problem comes: it's 4x slower than Live2LOVE. Live2LOVE took 1ms to render Kasumi (casual summer clothes) while my implementation took 4ms to render same model. I already code the implementation carefully so that LuaJIT happily accepts my code and won't bail out to interpreter as possible.

I started optimization by using "Data" objects instead of plain table when updating the "Mesh" object for drawing. This cuts down the update time significantly from 4ms to 1.7ms so using table to update the "Mesh" object is always a bad idea. Someone in LÖVE Discord then says "try to use all FFI instead of plain table". At first I did not agree with him because I want to preserve compatibility with mobile, but then I decide to proceed by having falling back to tables in case FFI is not suitable (JIT is off, FFI support is not enabled, or using vanilla Lua). I swapped most types from plain table to FFI objects and I can get as low as 1.2ms, almost close to Live2LOVE 1ms.

Conclusion

Re-implementing Live2D Runtime is a nice experience. It gives me better overview when to start optimizing code instead of optimizing early and overview of how Live2D model transformation works. Apparently it can't beat C++ version of the official Live2D Runtime in terms of model updating, but I think it can beat it in terms of model rendering. I'm thinking of "Mesh" batching technique, which is basically: accumulate vertices to render then draw'em'all at once if flush requested. I'm currently satisfied with the current result, but I think I still can do better
 

... and hope to God it can success without problems.

Friday, June 29, 2018

LÖVE Optimization Tips

It’s common for people to write game and then “damn it performs very slow”, or “ok this game is fast enough” then when testing in slower hardware it complains “damn it’s slow af”. So these points will help you to optimize your game for more performance. This assume you’re using LOVE with LuaJIT as LuaVM. If you have different configuration (like LOVE with Lua 5.2 or with Lua 5.1), some points in here may not relevant.

Note that these things may be considered as micro-optimization in desktop PC, but it’s noticeable in mobile.

Move your game computation in love.update, and keep love.draw solely for rendering.


Okay let’s see why. First, math computation in LuaJIT are compiled, which mean it’s fast. Second, calls to love.graphics api in LuaJIT are not compiled and break the code around that area to be not compiled. So instead of

function love.draw()  
 var1 = var1 + 20 -- this computation is done in LuaJIT interpreter  
 -- because calls to love.graphics.draw below can't be compiled  
 love.graphics.draw(image, var1 * 0.02, 0)  
end

Do it like this.

function love.update()
 -- this computation is done in machine code because
 -- it's compiled.
 var1 = var1 + 20 * 0.02 -- LuaJIT will optimize 20*0.02 to 0.4  
end

function love.draw()
 love.graphics.draw(image, var1, 0)  
end

A note: This is less relevant for mobile (iOS and Android), as JIT compiler is disabled by default.

Use SpriteBatch, especially if it’s static.


Admit it. All calls to SpriteBatch object method will be never compiled. As of LOVE 11.0, there’s a feature called autobatching. That’s it. It automatically batches your draws, much like SpriteBatch with “dynamic” and “stream” mode.

Now, why use SpriteBatch for static batches? That’s because it’s better to call love.graphics.draw once to draw many things than calling love.graphics.draw to draw each thing. The former saves CPU due to Lua to C API call overhead, allowing your game to run faster.

Only use pairs when you don’t have a choice.


Even with LuaJIT 2.1 trace stitching feature (allows uncompiled function like love.graphics.draw not to break JIT compilation), pairs still breaks JIT compilation because it uses uncompiled bytecode. You can check what things that are compiled or not in here.

If you really need to index by things other than number, store the key in numered array, index that, then use the value to index the actual table. Then you can use ipairs or simple for loop to iterate the table (both are compiled). Something like this.

myTableKey = {"key1", "key2","key3"}
myTableValue = {key1 = "Hello", key2 = "World", key3 = io.write}
for i, v in ipairs(myTableKey) do
 local value = myTableValue[v]
 -- do something with `value`.
 -- It can be either "Hello", "World", or `io.write` function.
end

Oh also pairs doesn’t guarantee the order they’re defined. When you run pairs in myTableValue above, key3 might be shown first, then key1, then key2.

Use io.write for simple debugging, not print.


print is not compiled, but io.write is. The downside of io.write is that you’ll lose of automatically separated values and object-to-string conversion feature. If you don’t care about this, you can use print. Well, this is solely a preference. If you need the full power of print, then use print. For very simple variable printing, use io.write.

Never use FFI for computation optimization purpose.


Very true if you’re targetting mobile devices. Check out this chat I took from LOVE Discord server.
A: Will vector-light be faster than brinevector?
B: Depends. Brinevector is faster, but only on desktop. It’s terribly slow on mobile. Vector ligh (hump?) Is faster, but the code is messy since thats only for vector operations
A: if I made an FFI version of vector-light that was just pure functions, it would be faster right?
That’s where the problem begins. Once you step into FFI, your phone will start to cry when running your code.
Wait, based on benchmark, FFI is faster than plain Lua table.
That kind of argument doesn’t work once you have JIT compiler disabled. I’ve tried to benchmark it that in most cases FFI is ~20x slower than plain Lua when JIT compiler is disabled. Then what’s the matter for mobile devices? Take a look at first tips above. Mobile devices have JIT compiler disabled by default, which means slow FFI.

Well, you shouldn’t use FFI in mobile devices at all anyway. Starting from Android 7, any calls to ffi.load will just fail due to changes in Android 7 dlopen and dlsym which restrict those function greatly that it makes ffi.load cease to function. And in iOS, I don’t see the point of using FFI as all things must be statically compiled.

Switch the JIT compiler before love.conf is called in conf.lua


You may ask yourself. Why? The answer is, LOVE utilize some FFI call optimizations when it detect it runs with JIT compiler enabled, mainly in love.sound and love.image modules, and the detection is done before main.lua is loaded. Some people did this wrong by turning the JIT compiler on/off in main.lua, which can lead to slower performance.

Conclusion


Those kind of optimization is mostly helps in CPU optimization. For GPU, you need to reduce drawcalls, reduce canvas usage (or don’t use it at all), and use compressed texture. If it’s still slower, but the CPU stays low, that indicates you’re fillrate-limited, in which case, any optimization in here doesn’t really help you at all.

Note that I may still wrong, so if you have something to add, or something is invalid in above tips, just comment below to discuss it.

'What was the original problem you were trying to fix?' 'Well, I noticed one of the tools I was using had an inefficiency that was wasting my time.'

Also, did I also mention that JIT compiler for ARM64 is unstable?