← Back to list

What Building Two WebGPU Engines Taught Me

Introduction

Faran Hosseini · 2026-06-16 03:20 · 3 claps · 9.3 min read
#webgpu #graphics-programming #game-development #javascript
Open on Medium ↗
Wiki topics: OPS · LLMOps & Inference 💻 · Programming 🌐 · Web Development

Render engine screenshot

Render engine screenshot

What Building Two WebGPU Engines Taught Me

Introduction

My name is faran and i’m 19 years old and self-taught, and over the past year I have built two WebGPU 3D engines from scratch.

The first one I completed. It worked, it rendered, it had GPU-driven rendering, compute-based frustum culling, a full glTF PBR pipeline. But as it grew, the cracks in its architecture became impossible to ignore. So I started a second one — not to finish it, but to fix what the first one got wrong.

This article is about those mistakes. But I want to be upfront about something before you read it: not all of these mistakes follow the same story. Some of them I saw coming from the beginning and avoided early. Some of them broke the first engine badly enough that they were the reason I started over. Some of them I caught inside v1 and fixed on the spot. And some of them — I only realized were mistakes later, and found out they still existed in v2.

I am not writing this from the other side of a solved problem. I am writing it from the middle of figuring it out.

If you have worked with Three.js or a similar abstraction and you are thinking about going lower level — or if you have just started building your own WebGPU engine and you are starting to feel the architecture fight back — this is for you.

Mistake #1: Skipping the Dependency Graph

When I built my first WebGPU engine, I had no dependency graph. At the time it didn’t feel like a mistake — you load a model, you render it, it works. The problem only shows up when you try to change something.

Say the user wants to swap the base color texture on a model at runtime. Simple request. But without a dependency graph, there’s no clean way to do it. You end up reaching directly into whatever store you’re keeping textures in, replacing it manually, and then realizing the logic for that is scattered — the model that pulled the texture in has its own references, the hash has its own entry, and you’re now rewriting extra logic just to keep everything consistent.

But the real problem goes deeper than inconvenience. In WebGPU, a texture change isn’t just a texture change. It can invalidate the bind group layout. Which means the bind group itself needs to be rebuilt. Which means the pipeline layout changes. Which means the pipeline needs to be recreated. And you’re doing all of this while trying to be smart about caching, because GPU resources aren’t free and you can’t just recreate everything on every frame.

Without a dependency graph, you end up writing this cascade logic manually, spread across multiple systems, and it becomes nearly impossible to maintain. Miss one link in the chain and you’re debugging why your pipeline is stale or your bind group is pointing at the wrong layout.

The dependency graph solves this by making the chain explicit. Texture → bind group layout → bind group → pipeline layout → pipeline. When something at the top changes, everything downstream knows it’s dirty and rebuilds in the right order, automatically. What was a maintenance nightmare becomes a single propagation pass.

Mistake #2: Binding Textures Individually

This one doesn’t hurt you immediately, which is what makes it dangerous.

When you’re starting out, binding textures one by one feels natural — you import a model, you pull its albedo, its normal map, its roughness, you bind each one and write the corresponding logic in your shader. It works. Then you add another model. Still works. Then you keep going, and one day you hit WebGPU’s hard limit of 16 sampled textures per shader stage, and suddenly you’re stuck.

The first instinct is texture packing — combining channels that don’t need their own texture into one. AO, metallic, and roughness are the classic example: instead of three separate textures, you pack them into the R, G, and B channels of a single texture. That gets you breathing room.

But packing alone isn’t scalable. As your glTF support expands and you add more material properties, you’ll eventually pack your way back into the same wall. The real solution is texture arrays — but those come with their own constraint: every texture inside a WebGPU texture array must be the same dimensions. So now you’re either resizing textures to match, or grouping same-size textures together into separate arrays, and tracking which array each texture lives in at runtime.

It’s a lot of logic. And honestly, if you’re building a small PBR renderer for a fixed set of assets, you might not need any of this. But if you’re building something that needs to scale — arbitrary glTF models, multiple materials, real-world asset pipelines — you need to plan for texture arrays from the start, not bolt them on later.

I solved this problem with packing in my first engine. The texture array path is where I would go next, though I haven’t fully built it out yet. I’m sharing it here because understanding where packing breaks down is half the battle.

Mistake #3: Not Thinking in Abstraction Layers Early Enough

If there is one thing that will save you the most code in engine development, it is abstraction layers. And this is not about being clean for the sake of it — it is about not painting yourself into a corner as your engine grows.

A good way to understand why is to look at Three.js materials. Three.js has MeshBasicMaterial, MeshStandardMaterial, and MeshPhysicalMaterial, each offering a different level of realism. But if you look closely, they all share certain properties — base color, opacity, side. Three.js doesn't implement those three times. It puts the shared logic in a base Material class and lets the rest inherit from it. Each subclass only implements what makes it unique.

The same principle applies directly to WebGPU engine architecture. In my engine I had several cache managers — BindGroupCacheManager, PipelineCacheManager, PipelineLayoutCacheManager. And naturally, they all needed the same things: a Map to store cached resources, a hashing utility to generate cache keys, and a getOrCreate function that checks the cache, creates the resource on a miss, stores it, and returns it. The only part that actually differed between them was how to create the resource when it wasn't in the cache.

The right move is a base CacheManager class that holds the shared Map and hashing logic, and declares create() as abstract — forcing every subclass to implement just that one piece. This is a well known pattern called the Template Method pattern: the base class defines the algorithm skeleton, subclasses fill in the one step that varies.

Without this, you write the same boilerplate across every manager. Which is annoying on its own — but where it really compounds is when you combine it with something like a dependency graph. If each material type in your engine has to wire up its own dependency graph from scratch, you are writing and maintaining that entire cascade — texture → bind group layout → bind group → pipeline layout → pipeline — multiple times. A bug in that chain means fixing it in multiple places. Put it in the base class once, and every material that inherits from it gets it for free.

The broader lesson is this: every time you find yourself writing the same structure twice, that structure belongs in a base class. The earlier you catch it, the less you have to refactor later.

There is another benefit to abstraction layers that I learned the hard way in my first engine: state tracking. When you pass raw WebGPU resources around — a bare GPUTexture handle, for example — you immediately lose visibility into what that resource actually is. You don't know if it has been disposed or if it still exists. You don't know its dimensions, its format, its hash, or where it lives in your cache.

In my first engine this caused real problems. In my second engine I built a proper Texture class that wraps the raw GPU handle and stores all of that metadata alongside it, plus a resource tracker that kept tabs on the lifecycle of every resource in the scene. It sounds like overhead but it pays for itself immediately — in debugging, in cache lookups, in knowing whether you need to recreate something or whether it is still valid.

A raw GPU handle is just a pointer. A class is a contract.

Mistake #4: Building Everything Inside the Engine Core

When I built my first engine, a lot of logic lived in one place. The more the engine grew, the more tangled it became — systems that should have been independent were reaching into each other, and adding anything new meant understanding everything around it first.

The fix is dependency injection, and the best way to understand why is with a simple example.

Say you have an Animator and a Model. The wrong approach is to put the animation logic inside the model itself — now your model class is responsible for its own geometry, its own materials, its own scene graph, and its own animation state. That is too much. The right approach is to create an Animator that receives a model as input and animates it externally. The model does not know or care that it is being animated. The animator does not know or care how the model was built. They are decoupled.

This pays off in a way that is easy to underestimate: someone extending your engine later needs far less knowledge of the whole system. They find the module they care about, understand its interface, and pass in what it needs. They never have to read the rest.

The renderer example makes this even clearer. Without dependency injection, if your renderer needs to handle both a raw model and a string path, you end up writing something like this inside the renderer itself: if the input is a string, fetch the model, create an entry, continue. If the input is already a model, create the entry directly, continue. That conditional has nothing to do with rendering — and it only grows. Next someone passes a URL, a binary buffer, a cached reference, and now your renderer has four branches at the top before it does any actual rendering work.

With dependency injection, the renderer’s contract is fixed: give me an entry. How that entry was produced — whether a loader fetched it, whether the user passed it directly, whether it came from cache — is not the renderer’s concern. That is someone else’s job, handled before the renderer is ever called.

This is the single responsibility principle in practice. Each system does one thing. It receives what it needs as input, does its job, and hands off the result. The engine core stops being the place where everything happens and becomes the place where everything is wired together.

Mistake #5: Not Having Managers for Everything

As your engine grows, you will notice a pattern emerging. You have wrapper classes — TextureWrapper, BufferWrapper, ShaderWrapper — that hold asset data and expose it through clean getters and setters. And you have systems like the renderer that need to work with those assets. The question is: who sits in between?

The answer is managers, and not having them early enough is a mistake that quietly makes everything harder.

Here is the problem without them. Say your renderer needs a final render entry to do its job. Without a manager layer, the renderer ends up accumulating logic that has nothing to do with rendering — hashing resources to check the cache, deciding whether to create a new pipeline or reuse an existing one, assembling bind groups, handling disposal. It becomes the place where everything happens by default, simply because there was nowhere else to put it.

The right structure is to give each part of that process its own manager. Your PipelineManager receives the relevant wrapper classes, hashes their state, checks its cache, and either returns an existing pipeline or creates a new one. Your BindGroupManager does the same for bind groups. Each manager has one job. The renderer just calls them in order and gets back what it needs.

The key rule that makes this work: managers never reach inside a wrapper class and mutate its data directly. If something needs to change inside a TextureWrapper, the manager calls the method the wrapper exposes for that purpose. The wrapper owns its own state. The manager orchestrates, it does not intrude.

When you follow this consistently, your engine ends up with a clean three layer architecture. Wrapper classes own data. Managers perform actions. The engine core wires everything together. Each layer knows exactly what it is responsible for and nothing more.

And when you combine this with everything discussed in the previous sections — a dependency graph propagating changes, abstraction layers eliminating duplication, dependency injection decoupling your systems — you stop writing an engine that works and start writing an engine that scales.

Closing Thoughts

I am 19 years old, self-taught, and I have built two WebGPU engines from scratch. The second one exists because the first one taught me some of things in this article.

That is kind of the honest summary of engine development — you will not get it right the first time, and that is fine. The mistakes I described here are not things I read about in a textbook. They are things I ran into, got stuck on, and had to think my way out of. Some of them I caught early. Some of them cost me a full rebuild.

WebGPU is still young. The documentation is sparse, the resources are scarce, and most people building with it are figuring it out as they go. That is actually what makes it worth doing. The people learning this now are the ones who will be writing the resources that did not exist when they started.

If you are building your own engine, my honest advice is: build the wrong version first. Not on purpose — just do not be afraid of it. The wrong version is what gives you the intuition to build the right one.

You can find me on my Portfolio or follow me on Linkedin . You can see the engine codes on my Github


메타데이터
post_id
ee72a2a0dfcb
slug
what-building-two-webgpu-engines-taught-me-ee72a2a0dfcb
url
https://medium.com/@fdevzx/what-building-two-webgpu-engines-taught-me-ee72a2a0dfcb
canonical_url
https://medium.com/@fdevzx/what-building-two-webgpu-engines-taught-me-ee72a2a0dfcb
author_url
https://medium.com/@fdevzx
status
ok
fetched_at
2026-06-27 07:40:21