Articles & experiments /Development notes

Running a 3D pumpkin app

An edit can finish after the person has moved on. Keeping the right result on screen leads into workers, shader preparation, resource cleanup and what our profiling actually showed.

This series · Part 6 of 6Drop Dead Gourdgeous
  1. Drop Dead Gourdgeous
  2. Making clean pumpkin cuts
  3. Scraping pumpkins
  4. Meshes, heightfields and volumes
  5. Building a candle maker
  6. Running a 3D pumpkin app
Series overview

An old edit must not replace a new one.

Make a candle tall, then change it to short and wide while the first version is still being built. When that old build finishes, it must not put the tall candle back on screen. In Drop Dead Gourdgeous, the person should be able to keep adjusting the shape without waiting for every detailed rebuild.

The candle tool shows a quick mesh as soon as the settings change. A more detailed mesh is built separately. Each build carries a counter value identifying the edit it belongs to; after the wait, that value is checked against the current one. A newer edit advances the counter, so the old result is discarded. Disposing the candle view advances it too, so a finished job cannot revive an object whose view has been released.

Two paper candles joined by a rightward arrow. The first is tall and narrow, labelled Edit A. The next is short and wide, labelled Then edit B.Edit AThen edit B

An edit can overtake its own rebuild

A detailed build starts for the tall candle. Before it finishes, a new edit makes the candle short and wide. The quick preview already shows B.

The same tall and short candles now have detailed puddle shapes. The old tall candle is crossed out and labelled Discard A. The short current candle has a check mark and is labelled Keep B.Discard AKeep B

Finish order does not decide what is shown

The old A result is discarded. B is still current, so its detailed mesh can replace its preview. Disposing the view also prevents a late result from appearing.

A simplified candle rebuild: edit A starts a detailed mesh, then edit B changes the preview. A may finish later, but its old result is discarded. Only the current edit can replace the preview. These shapes illustrate the rule, not measured build times or a wax simulation.

That small handoff is one part of running a creative tool. DDG lets someone reshape, carve, scrape, paint and light a pumpkin. Across those tools we have to keep input moving, prepare what will be shown next and release what is no longer needed, while preserving the editable design. The interesting work is in how those responsibilities meet.

Back to contents

Keep input moving while geometry catches up.

A shaping drag produces settings faster than a worker can build them. Queue every pointer sample and the app spends its time constructing pumpkins the person has already moved past. Wait for each build before moving the handle and the drag itself feels stuck.

The shaping tool allows one worker request in flight and keeps only the newest pending settings. When that request returns, a reply matching it can update the preview if it is newer than the last applied result and the gesture is still active. Then the worker takes the latest pending settings. Unlike the candle handoff, an intermediate shape can be useful here: it lets the preview advance while the next shape is being built.

Releasing the handle settles the final shape or the last accepted preview. Any later idle catch-up checks that it still belongs to the current operation and that its owner has not been disposed. The main-thread fallback has no worker preview to rely on and performs the drag’s exact rebuilds; moving work to a worker is a capability of one path, not a guarantee that every device runs it.

Scraping uses the same idea with a different rule. It leaves the old patch visible while building its replacement, then installs the new patch only if its state key still matches the requested surface. Disposing the scraping view terminates the worker, rejects waiting requests and cancels staged installation. Sending work elsewhere helps the interface only when we also control which result is allowed back.

Back to contents

A finished mesh can still stall its first frame.

Accepting the right geometry does not mean it is ready to draw. The version of DDG examined here uses Three.js 0.185.0 through WebGPURenderer. Textures may still need decoding and uploading, the material’s shader graph needs building, and the GPU needs pipelines: prepared combinations of shaders and drawing state. Downloading the files covers only the first part.

We tried preparing the future carving scene before showing it. The catch was that its lights, fog and drawing passes mattered too. Preparing it under the opening scene’s state warmed a combination that disappeared at the handoff, so the supposedly ready object compiled again. Shadows, contact shadows and depth of field also needed their own drawing paths exercised.

The app now draws upcoming objects through their destination state in small hidden slices, borrowing the required lights, environment and passes before restoring the visible scene. That restoration has to include the pass cache. One version left it holding the hidden table, which briefly appeared in the next visible frame. Advancing the render-pass frame identity before and after preparation makes the visible draw run its own passes again.

Even asynchronous GPU compilation leaves JavaScript work to do. Building a whole pumpkin’s shader graphs in one hidden draw could still cause a long frame. The scheduler normally prepares one mesh per slice, pauses optional work during a gesture and permits larger batches while the person is already waiting. Smaller slices take longer to finish overall, but leave more opportunities to respond between them.

An earlier captured Drop Dead Gourdgeous opening, with pumpkins around the title and a growing vine below the central pumpkin.
An earlier opening capture. A matching still gives the app something useful to show while it builds and draws the live scene behind it. The handoff needs a prepared first frame, as well as downloaded assets.
Back to contents

Release the picture; keep the work.

Preparing the next view creates another tradeoff: fewer first-use pauses can mean more resources kept alive. The preparation manager runs one item at a time, promotes dependencies and evicts releasable ready items by priority and age. Its default 240 MB budget adds up declared costs for prepared items. It is an estimate for that queue, not a measured limit on the whole tab.

When the app switches scenes, it takes ownership of the prepared backdrop and disposes the old one. Removing a mesh from a scene is not enough to release its GPU resources, and callbacks can keep its JavaScript objects alive too. In the working-pumpkin cleanup, terminating the crease worker mattered because its listener retained the pumpkin and carving session.

We also found a less obvious chain: long-lived textures retained old bind groups, which retained node graphs, whose callbacks retained builders and meshes. Disposing the mesh did not break that chain. Our pinned Three.js release has a patch to prune dead groups, alongside fixes for builder scratch data and late-uploaded geometry attributes. These are version-specific repairs with removal conditions to check on upgrade.

Keeping the design does not require keeping every rendering object. When a new pumpkin shape is chosen, its predecessor’s cuts and paint are stored with that choice so Undo can restore them to the correct surface. On phone-class devices the app can keep the work record and rebuild the old carving, rather than retaining two complete sessions. That costs reconstruction time in exchange for lower memory use.

The same distinction supports recovery. Page exit snapshots and saves the carving; reload replays the lid, cuts and scrapes. Its timeout is 30 seconds without progress, allowing a slow but advancing replay to continue. Unexpected GPU loss also tries to save before reloading, with a guard against repeated reloads. An operating system can still kill a tab before it gets time to save, so recovery cannot depend on that final callback alone.

Back to contents

Did we shorten the pause, or just move it?

Moving work into preparation can make a transition look better while leaving a pause elsewhere. We built a harness that drives named steps through the actual app and records frame intervals, long animation frames, long tasks and timing spans around our work. CPU profiles with source maps help connect a stall to the functions responsible. Timing and attribution runs answer different questions: collecting a profile adds work of its own.

The “6× test” makes CPU work more expensive by setting Chromium’s DevTools CPU-throttling rate to six. It is useful for exposing a large build that a fast desktop hides. It does not slow the graphics card into an iPhone GPU or reproduce Safari, phone memory limits or heat. We also need cold-cache runs, because an already compiled driver cache can hide first-use work.

In the October 20, 2026 notebook, two runs before and after idle preparation reduced the worst Design-entry frames from 800/850 ms to 483/517 ms under 6× CPU throttling. Prepare-entry frames fell from 967/1,033 ms to 433/467 ms. Those are stalls inside the entry window, not total transition time or tap-to-first-frame latency. Prepare still missed the stated target of a worst frame below 400 ms. Shared GPU load and full swap were recorded during the investigation, so these observations do not establish a clean speedup on phones.

Repeated use needed a separate question: what stays alive after each edit? A Make → Back → Design → edit → Done loop, with forced garbage collection and heap snapshots, helped trace the retention chains above. In a historical October 22 ten-cycle run after the bind-group fixes, JavaScript heap rose from 234.6 to 237.1 MiB between cycles three and ten. That endpoint average is about 0.36 MiB per cycle, compared with roughly 27–28 MiB per new pumpkin before the fixes.

A flat heap still missed a paint-target texture leak. The texture count rose from 82 to 90 over eight edits; after a separate fix it stayed at 82. Heap snapshots, finalization probes and a render-object ledger were useful together because no one number covered every resource. The app’s optional ?perf=1 overlay adds frame gaps, preparation readiness, backend and pixel ratio for device checks. Those frame gaps come from requestAnimationFrame, not GPU execution time; real phone use remains part of judging the result.

Back to contents

What this taught us.

Responsiveness depends on more than making a calculation faster. A late result needs an owner, a prepared material needs the state it will actually draw in, and a completed edit needs a way to release its rendering resources without losing the design. Our profiling became more useful when we asked those questions separately and kept the missed targets visible.

This is also where a fuller framework or engine could help a small team: asset management, shader preparation, scene tools and profiling could replace some of the infrastructure we maintain. How much they would save DDG—and what a move would cost—deserves its own hands-on evaluation. We will return to that comparison once we have tried the alternatives ourselves.

Back to contents