鄧 Emblem
Kevin Dang

Rendering Pokémon Cards with R3F

Having a bit of fun playing around with textures in three.js using R3FPublished on: October 28, 2025

"TL;DR — show me the cards 👀"

A GIF showing a preview of a Dragonite V card

  • Go to the demo site here

Three.js + R3F

I used three.js and react-three-fibre (R3F for short).

R3F is a React renderer and greatly simplifies the three.js development process. This is demonstrated in their "Your first scene" docs when adding a mesh component — the API is amazing and hides a lot of the rendering setup intricacies and complexities that you would encounter if doing it in vanilla three.js.

R3F also provide a lot of useful utility packages for you to use (you can check out their Ecosystem section in the README for a full list). I made use of the drei and postprocessing packages; the former to add OrbitControls to support user interaction within the canvas, and the latter to apply effects using their EffectComposer (this is what's adding the sheen effect you see when you rotate at certain angles).

Challenges

Lighting

In order to render both the front and back of the card, I needed 2 meshes — this was because you're only able to apply a texture to one side of a mesh. When applying the texture, I realised that both meshes faced in the same direction but I needed one to be flipped 180°. To fix this, I applied rotation on the y-axis (i.e. rotation={[0, Math.PI, 0]}) to the mesh rendering the back of the card.

But the result was unexpected and I kept seeing a black gradient get rendered at the back of the card rather than my texture:

A GIF highlighting the black gradient at the back of the card

After a long time of debugging, I realised that the issue was to do with the lighting in the scene — specifically the position attribute that was set on one of the lighting components. It was applied in a way where it was not reaching the back pane of the mesh, and so it wasn't being lit up. A note to myself for the future is to initially choose a lighting configuration that doesn't have a prominent direction or placement so that I can avoid a similar headache in the future.

A GIF showing a preview a Dragonite V card where it's rotating horizontally to showcase the back of the card

Performance

I discovered that there were limits to how many canvas elements you could render (message to past Kevin: "DUH!"). The issue was more prominent on mobile devices as their GPUs are not as powerful as desktop machines, but I did also catch it happening on desktop sometimes as well.

To counter this, I could have rendered a single canvas with external UI controls to cycle through the different previews, but I wanted to render multiple of them in a grid.

To support this, I decided to lazy load the canvas elements.

💡 Lazy loading means that we only load things when they're needed — in our case, we only want to load the canvas elements that will be in the "visible viewport".

I utilised the browser's Intersection Observer API to help detect when the canvas element would be in view.

If you scroll the page quickly on the demo page, you should see the lazy loading in action.

Mobile UX issue

When I was testing on mobile, I noticed that page scrolling was impacted by the canvas. Essentially, there was an interference between the two: my canvas allows the user to rotate the card, but in doing so, it took precedence over another touch event — page scrolling.

My initial thoughts were to disable OrbitControls on mobile (OrbitControls is a utility from R3F's drei package to add user interaction like panning, zooming, rotating, etc. in the canvas). This worked, but I found that the overall experience suffered greatly; having a static render is no fun, and you lose the ability to rotate 360° to see the entirety of the card render.

To compromise, I decided to add a larger x-margin on the canvas container on mobile devices so that there was more real estate for a user to scroll on the page sides.

Other developers have encountered similar issues, take this GH issue for example. In that thread, there are a couple of solutions proposed, but the general consensus seems to be to limit the control on mobile devices. I still wanted to retain the OrbitControls so opted against this and settled with extra margins instead.


Demo site

Here's the demo site.

Back to all posts