I Built Nova in 3D, Then Painted Over Her

The first version of Nova was a drawing I wrote by hand, in code, one curve at a time. It was bad. Three tries in a row, each one fixed a flaw and introduced a new one. First she was all head. Then she was floating above the ground. Then she was mostly white, which for a Bernese Mountain Dog is like drawing a zebra without stripes. Looking at that third screenshot, I understood something I should have known already: you can't write "adorable" in Bézier curves (the math that draws smooth lines in vector graphics). Somebody, or something, has to actually draw her.

Nova is the first of a family. Every palStack product is getting its own dog as a mascot, and Nova, finPal's, is the one I've learned the most from so far.

That first drawing was September 22. Three weeks later Nova trots onto the screen in finPal, sits down facing you, and has ten moods. She still isn't where we want her to be, and we're still working on her. Getting there meant learning a corner of software I had never touched: 3D models, rendering, and motion. Here's how it went, including the part where I threw the 3D away.

Why finPal has a dog at all

finPal is our money app. The premium version has a small game inside it. You earn coins by doing the things that actually make you better with money, like finishing a short lesson or sticking to a budget, and you spend those coins on climbing gear. There are 21 pieces, from a map to an oxygen tank. The story is that Nova, a Berner, is climbing Everest, and you help her get up the mountain by learning about your own money.

Nova is real. She's a Bernese Mountain Dog with a white blaze down her face, rust-colored eyebrows, and a small pink spot on top of her black nose. That pink spot became a line in every prompt I wrote for the next three weeks.

Before any art existed we set two rules, and they shaped everything after. Nova is decorative. She can be cute, she can celebrate with you, but no fact in the app can live only in her pose. If she's cheering because you reached a goal, the words saying you reached the goal are on the screen next to her. And she is never disappointed in you. finPal's promise is that no screen tells somebody they failed, and a dog with sad eyes next to a negative balance would break that promise faster than any copy could.

Learning to make a 3D dog

The hand-drawn attempt told me the art had to come from somewhere else. The long-term plan was, and still is, to commission an animator. But I didn't want Nova to wait on a commission, so I tried generating her.

I used Meshy, a tool that turns a text description into a 3D model. It works in two passes. The first pass, called preview, builds the shape: the body, the legs, the ears, as a mesh of about 60,000 triangles. The second pass, called refine, paints the surface with textures, the colors and fur that sit on top of the shape. You get back a GLB file, which is the standard format for shipping a 3D model as a single file, the way a PNG is for an image.

The prompt did most of the work, and it had a hard limit of 800 characters. I learned fast that every word mattered. "Puppy" gave me something chunky and cartoonish. "Adult female, slim and graceful, head slightly large for appeal" got closer. Her eyes kept coming out heavy-lidded and sleepy until I wrote "wide open, no droopy lids" in so many words. A tuft of hair kept appearing on her head that real Nova doesn't have, so "smooth rounded head, no tuft" went in too. Then the green collar with a gold coin tag, because coins are the currency of the game.

For the third version I also handed Meshy one of her real photos as a texture reference, so the coat would look like her coat and not a generic Berner's. Later I found out the photo had been silently dropped. The response came back with that field empty, so every improvement in version 3 came from the rewritten prompt alone. A texture can't change a shape anyway. I rendered the versions side by side next to two photos of Nova lying in the grass. Version 2 looked like a toy. Version 3 looked like a dog that had seen something upsetting. Version 3b, a second run of the same prompt, was the closest.

Three 3D renders of a cartoon Bernese Mountain Dog, labelled v2, v3 and v3b

Three text-to-3D runs from Meshy. v2 is the toy, v3 the tired one, v3b the closest to Nova.

Then I needed her in two poses, and that's where generation gets unpredictable. The sitting Nova worked on the first try. The standing one took three misses: one with no markings at all, one that was a realistic dog half sitting down, and one with two heads. What finally worked was a trick I didn't know existed. Every generation starts from a random number called a seed, and if you reuse the seed from a run you liked and change only the pose, you get the same dog standing up instead of a stranger.

Four 3D renders of a standing dog: one with odd markings, one with two heads, one half sitting, and the kept version

Four tries at a standing Nova. The fourth reused the seed from the sitting model and changed only the pose.

A 3D model in an app that can't see 3D

Here is the problem nobody tells you about when you put 3D in a product. In finPal, a lot of the testing is automated walks: scripts that open every page, at phone and desktop width, in light and dark mode, and check things like text contrast and whether anything overflows the screen. Those scripts read the page's HTML. A 3D scene in a browser is drawn on a canvas element, and to those scripts a canvas is one blank box. Nova would have been the only part of the app our checks couldn't see.

So we didn't ship the 3D model. We shipped pictures of it. A small script loads each model into a headless browser (a browser with no window, driven by code), points a camera at Nova, takes a screenshot with a transparent background, and saves it as an image under 60 KB. The app shows that image like any other. You keep the lighting and depth of 3D and lose none of the testing.

That script taught me my favorite lesson of the whole project. If the model fails to load, the browser doesn't crash. It quietly paints an empty, transparent frame, and saves it. An empty transparent image looks exactly like success. Nothing errors, the file exists, the build passes, and Nova is invisible. So the script now checks every frame it renders, retries a blank one, and fails loudly if it's still blank.

We ended up with one model per pose. A standing Nova for when she walks onto the screen, and a sitting Nova for her basecamp, the page that used to be called Profile. When something good happens, like earning a badge or finishing a lesson, she trots in from the side, sits down facing you, and the message appears next to her.

The day she read as creepy

On October 1 I looked at her again, rendered and live in the app, and the honest word was creepy. Not broken, not low quality. Just slightly wrong in the way generated 3D faces often are. The eyes were a little too glossy, the smile a little too fixed. It's the uncanny valley, the dip in comfort you feel when something is almost lifelike but not quite, and a mascot can't live there.

So I went back to her photos and tried a different tool for the same job. Instead of text to 3D, I used image-to-image generation, which takes a picture and redraws it in a style you describe. I fed it real photos of Nova and asked for two looks, one close to a photograph and one like a soft painting, across five scenes for onboarding: waving hello, lying next to a trail map, looking up the mountain, wearing a little green backpack, and sitting on a summit with a flag. The painted set won right away. It looked like her, it looked warm, and nobody would mistake it for something trying to be real.

Five painted illustrations of a Bernese Mountain Dog: waving, lying by a map, looking up, wearing a backpack, sitting on a summit

The painted onboarding set, made with image-to-image from her real photos.

A glossy 3D render of a sitting dog beside a soft painted illustration of the same dog

Same pose, two techniques. The 3D version was retired on October 1.

The 3D work didn't disappear. The models are archived, and the rendering script still exists, but it now refuses to run unless you pass a flag that says you really mean it, because running it would silently paste the old 3D renders over the new paintings.

Ten moods from one painting

Once she was painted I wanted Nova to react to more than one thing. So I generated reaction poses: thumbs up, a happy jump, celebrating with both paws up both sitting and standing, curled up asleep, a curious head tilt. Each one is generated with the painted onboarding images as reference, so the markings, the collar and the coin tag stay the same from pose to pose. A character that changes slightly every time you see her stops feeling like a character.

Each generated pose comes on a plain cream background, which has to go. My first instinct was to remove every pixel close to that cream color. The problem is that Nova has a white chest, white paws and a white blaze, and they're close to cream too. So the cut-out script starts at the edges of the image and only removes cream it can reach from the outside, the way a paint bucket fill works. Her white chest is surrounded by black fur, so the fill never reaches it. There's a comment in that script, from a day I spent getting it wrong, that says not to raise the tolerance past 54, because anything looser punches holes in her face.

In the code, a screen never asks for a file. It asks for a mood, like "curious" for an empty page or "celebrate" for a big milestone, and one small registry decides which image that means. When a new pose gets made, nothing else in the app has to change.

Ten painted poses of Nova labelled with mood names such as thumbsUp, cheer, curious and sleepy

Nova's ten moods. Each one is a cut-out from a painted image, named by what it means rather than by its file.

Then everything else started moving

Nova was the first thing in our apps that really moved, and doing her properly forced a question I hadn't asked before: what should happen for people who don't want motion? Phones and computers have a setting, reduce motion, for people who get dizzy or distracted by animation. For Nova's entrance we used the same class names for both answers. With motion on, she trots in. With reduce motion on, she simply fades in. Same code path, same events, so the app can't behave differently for one group by accident.

That turned into a pattern for the other pals: a shared kit of named moves, each with a calm version, and a rule that we measure what a page does before we add anything to it. I wrote about that work separately, in The Pals Learn to Move.

What I'd tell myself three weeks ago

Generating 3D is now easy enough that the hard part isn't making a model. It's knowing what to do with it once you have one. Rendering it to stills kept it inside the checks we already trust. Throwing it away when it felt wrong, even after all that work, was the right call, and I'm glad the decision took a day and not a month.

Nova is also only the first. The goal is for each of our products to have its own dog as a mascot, so that together they're your day-to-day pals: one for your money, one for your home, one for your kitchen, one for your friends. Dogs, because that's what a pal is in real life, the friend who's around for the everyday stuff, happy to see you, never keeping score. propertyPal's dog will live where the app feels emptiest today, like the 404 page that doesn't exist yet and the empty states. clubPal's comes after its party-invite release, and Biscuit is the leading name. pantryPal's is next in line after the new meal planner. This time we start with the lessons already learned: still poses before anything moves, a still version of every animation for people who turn motion off, and the same promise Nova makes. They're never disappointed in you.

The plan for an animated Nova hasn't changed. Someday a real animator will give her a walk cycle, a breathing idle and a little spin when you hit the summit, and the brief for that is already written, down to which bones the backpack and helmet hang from. Until then she's a painting that trots in, sits down, and is happy to see you. She isn't finished, and I'd rather say that plainly than pretend otherwise. We'll keep working on her until she feels like the real Nova, and I'll write about it when she does.

← Previous post Next post →