Vol. 07 · Dispatch2026-08-11

Zero art assets — building every polygon, texture, and shader in code

Pokémon of the Forest ships no GLB files, no PNG textures, no baked normal maps. Every creature is marching-cubes-meshed from Wyvill metaballs at boot; every leaf is a 2D-canvas Bézier fill baked into a CanvasTexture; every terrain patch is an analytic heightfield sampled onto a PlaneGeometry; every skin material is a MeshPhysicalMaterial with a custom subsurface-wrap term injected via onBeforeCompile. This post walks the seven techniques that let a browser game with ~24k lines of TypeScript render Pallet Town without downloading a single texture.

by MetaWorldOS Engineering
Three.jsWebGLProceduralGenerationMarchingCubesShaders

TL;DR — The engine directory of Pokémon of the Forest contains one thing: .ts files. No .glb, no .png, no .hdr. Bulbasaur is a set of tuned metaballs run through marching cubes; his dark dapples are projected ellipses painted into vertex colors; his skin is a MeshPhysicalMaterial with a subsurface-wrap chunk spliced into the fragment shader via onBeforeCompile. Grass blades are Bézier fills into a 512×512 <canvas> promoted to CanvasTexture. Terrain is a PlaneGeometry displaced by an analytic FBM heightfield sampled at every vertex, with normals from central differences of the same field. Trees are swept Catmull-Rom trunks with leaf-cluster billboards scattered by rejection sampling on a plantability grid, rendered with a single InstancedMesh and a hand-rolled per-instance frustum culler. This post is a tour of the seven techniques that make the runtime asset-free.

Code: src/features/gamecenter/games/senlin-baokemeng/engine/

Games that ship in the browser have a rough deal on assets. A modern Pokémon game is hundreds of MB of textures and rigs before it even boots. Streaming that off next/static isn’t going to load in the two seconds you have before someone closes the tab.

The alternative most web games take is to ship a small asset budget — a handful of GLBs, maybe a spritesheet — and lean on procedural techniques where they can. Pokémon of the Forest is the extreme version of that idea. The engine directory has no art files at all. Every polygon and pixel is generated at boot from ~24k lines of TypeScript. Boot payload for the world is JavaScript, not textures.

Here’s how it holds together.

1. Creatures are metaballs run through marching cubes

The problem: a Pokémon body is a set of soft, blended volumes — a head fused to a torso, legs merging into a belly, a bulb rooted into a back. Parenting primitives leaves seams. A hand-sculpted mesh solves it but requires… a hand-sculpted mesh.

The engine uses metaSurface in fx/Sculpt.ts instead. You author each creature as a list of Ball records — position, radius, optional anisotropic scale, optional negative strength for carving — and marching cubes returns a single continuous mesh:

export function metaSurface(
  balls: Ball[],
  opts: { resolution?: number; isoLevel?: number; padding?: number; smooth?: number } = {},
): THREE.BufferGeometry {
  // ...bounds from positive balls, padded...

  const field = new Float32Array(nx * ny * nz);

  const sample = (px, py, pz) => {
    let sum = 0;
    for (const b of balls) {
      const ex = (px - b.x) / (b.sx ?? 1);
      const ey = (py - b.y) / (b.sy ?? 1);
      const ez = (pz - b.z) / (b.sz ?? 1);
      const d2 = ex * ex + ey * ey + ez * ez;
      const r2 = b.r * b.r;
      if (d2 > r2 * 4) continue;
      // Wyvill falloff: finite support, C2 continuous, no long-range bulge.
      const q = clamp(d2 / (r2 * 4 * smoothK), 0, 1);
      const f = 1 - q;
      sum += (b.strength ?? 1) * f * f * f;
    }
    return sum;
  };

  // Fill the field, then marching-cubes an iso-surface.
}

Two subtle choices are worth calling out:

  • Wyvill falloff, not Blinn’s Gaussian. Wyvill is (1 − d²/R²)³ clamped to a finite support radius. It’s C² continuous, so the surface stays smooth under normal recomputation, and — crucially — it stops contributing past 2R. A Gaussian never quite dies; every ball fights every other ball everywhere, and you get long-range bulges where a leg drags mass into a wall it shouldn’t touch.
  • Anisotropic scale on the ball, not on the position. sx/sy/sz divide the sample offset before the falloff. That’s what makes a leg look like a leg rather than a stack of spheres — you get one long ellipsoid with continuous curvature, and the marching cubes has enough field gradient to produce clean normals along the axis of elongation.

Bulbasaur is 20-ish balls, Charmander a few more, Squirtle sits closer to 15 because his shell hides half the topology. The resulting meshes are 5–15k triangles. Nothing streams.

2. Vertex colors are the texture channel

Marching cubes gives you positions and normals. No UVs. You could try tri-planar mapping, but for a soft cartoon skin with hand-placed patches, that’s the wrong tool — the seams between projection axes always show up as banding across the belly.

Instead: paint markings directly into a vertex-color attribute, and read vColor in the shader. ensureVertexColor allocates a color buffer if the geometry doesn’t already have one; paintSpots walks vertices and modulates the color by a distance-to-ellipse test:

interface Spot {
  axis: 'x' | 'y' | 'z';
  face: number;   // +1 or -1 — which side of the axis the patch faces
  u: number; v: number;
  ru: number; rv: number;
}

function paintSpots(geo, spots, tint, opts) {
  // For each vertex:
  //   for each spot:
  //     gate by (surface normal · axis · face) — patch is only on the correct side
  //     compute (du, dv) as distance from spot center, both perpendicular to axis
  //     mask = smoothstep(edge feather) with a light noise warp on du/dv
  //   blend tint by max mask
}

Two things make this work on a metaball body:

  • Spots are projected ellipses in the two axes perpendicular to axis. A “solid” 3D ellipsoid of pigment does not work — the metaball iso-surface crosses well outside the nominal ball radii, and a hand-placed pigment volume is usually buried entirely inside the skin, painting nothing. Projecting drops the radial coordinate: a flank spot becomes a (y, z) disc extruded through the whole X extent, and it lands on the skin at the point the skin faces +x.
  • The facing gate is near-binary, not a smooth ramp. smoothstep(0.0, 0.14, nx * face). Pokémon HOME’s markings are flat stencil shapes with hard edges; a wide gradient here smears every patch into a washy streak wherever the body curves. The cut has to be sharp.

Bulbasaur’s dapples are ten hand-placed spots. Charmander’s belly is one big projected disc. It’s stencil authoring in a for loop.

3. Skin shading is onBeforeCompile on MeshPhysicalMaterial

The stylized soft-plastic look needs subsurface wrap and a rim term. MeshPhysicalMaterial doesn’t ship those. Writing a raw ShaderMaterial gives them to you but you lose shadow maps, envmap prefiltering, tone mapping, and every fog/dithering chunk the standard material handles.

The compromise the engine takes across CreatureMaterials, FoliageMaterials, LabMaterials, BuildingMaterials, and TerrainMaterials: start from MeshPhysicalMaterial, then splice custom GLSL in via onBeforeCompile:

const mat = new THREE.MeshPhysicalMaterial({
  color,
  roughness,
  clearcoat: 0.28,          // waxy sheen; more than this reads as bath toy
  clearcoatRoughness: 0.62,
  sheen: 0.35,
  sheenRoughness: 0.8,
  sheenColor: new THREE.Color(subsurface).multiplyScalar(0.5),
});

mat.onBeforeCompile = (shader) => {
  shader.uniforms.uSubsurface = { value: sub };
  shader.uniforms.uWrap = { value: wrap };
  shader.uniforms.uRim = { value: rim };

  shader.fragmentShader = shader.fragmentShader
    .replace('#include <common>', /* glsl */ `
      #include <common>
      uniform vec3  uSubsurface;
      uniform float uWrap;
      uniform float uRim;
    `)
    .replace(/* … splice a wrap+rim term into the lit output … */);
};

You get: THREE’s lighting integration, shadow chunks, tonemapping, fog — and one extra term added on top. This pattern is the workhorse of every non-trivial material in the game. It’s fragile — Three’s shader chunks change between versions and every splice is a small target — but it’s the only way to get PBR-integrated stylization without rewriting the entire lighting pipeline.

4. Terrain is an analytic heightfield, not a heightmap texture

buildTerrain takes a seed and constructs a field object — pure JS, no textures — with two methods: height(x, z) and surface(x, z). Every terrain vertex asks the field for its height directly:

for (let i = 0; i < count; i++) {
  const x = pos.getX(i);
  const z = pos.getZ(i);
  pos.setY(i, field.height(x, z));

  // Central differences of the analytic field — smooth shading with none of
  // the faceting computeVertexNormals() leaves on a low-amplitude grid.
  const dhx = (field.height(x + e, z) - field.height(x - e, z)) * inv;
  const dhz = (field.height(x, z + e) - field.height(x, z - e)) * inv;
  const len = Math.hypot(dhx, 1, dhz);
  nrm.setXYZ(i, -dhx / len, 1 / len, -dhz / len);
}

The field.height sampler is a stack of Simplex FBM octaves with domain warping — a coarse continental term, a medium ridge term for the northern hills, a fine detail term for the roll of a lawn, all masked against a plantability grid so the path is graded flat. Because it’s a function, not a texture, the same sampler serves as:

  • The Y coordinate of terrain vertices.
  • The groundHeight(x, z) callback the player controller reads to snap the character to the ground.
  • The surfaceAt(x, z) callback vegetation scattering uses to reject saplings on the road.
  • The gradient source for splat-map baking.

No sampling artifacts. No texture-size ceiling. The world is as high-frequency as the sample rate.

The central-differences normal is important: computeVertexNormals averages faces, which on a low-amplitude terrain produces visible faceting under a raking sun. Central differences of the analytic field give you the true surface gradient at each vertex — smooth shading everywhere, at the cost of two extra height samples per vertex.

5. Textures are canvases

Where the game does need a texture — a leaf card, a bark tile, a grass clump for alpha-cut billboards — it draws it on a 2D <canvas> and wraps it in a CanvasTexture. grassCardTexture is representative:

export function grassCardTexture(key: string, seed: number, blades = 11): THREE.Texture {
  return cached(`grasscard.${key}`, () => {
    const size = 512;
    const canvas = document.createElement('canvas');
    canvas.width = size; canvas.height = size;
    const c = canvas.getContext('2d')!;

    const rng = makeRng(seed);
    const list: Blade[] = [];
    for (let i = 0; i < blades; i++) {
      list.push({ x0, lean, h, w, tint, z: rng() });
    }
    // Back-to-front sort so near blades overlap cleanly.
    list.sort((a, b) => a.z - b.z);

    for (const b of list) {
      // Bézier spine from base to tip; sample the spine and offset by a
      // tapering half-width to build a filled outline. A stroked line cannot
      // taper; a filled outline can.
      const spine = quadraticBezierSamples(base, ctrl, tip, 16);
      c.beginPath();
      // walk spine building left/right lists, fill.
    }
    return new THREE.CanvasTexture(canvas);
  });
}

Two things:

  • cached(key, fn) memoizes by name. The bark for every tree isn’t 200 unique bakes — it’s one bake, one texture, shared. Grass cards likewise share by biome key. The heavy lifting happens once, at boot.
  • Filled outlines, not stroked lines. A stroked line has constant width; a grass blade has to taper. So the code samples the Bézier spine at 16 steps, offsets each sample perpendicular to the spine by a decreasing half-width, and fills the resulting polygon. This is the same trick that produces the tapering brush stroke in vector illustration apps.

The same file bakes leaf clusters (Poisson-disk-packed lobes with per-leaf tint), bark (multi-octave Worley + Simplex, painted with a nib for the fissures), and litter (small twig and leaf debris scattered on the ground plane). Everything comes out as a CanvasTexture — no image download, no decode.

6. Sky is an analytic dome, sun disc deliberately > 1.0

SkyShader.ts is one ShaderMaterial on an inverted sphere. The fragment blends a broad zenith→horizon gradient with a tight haze band and a Mie forward-scatter lobe around the sun. Everything is in HDR — the sun disc is > 1.0 linear so the post-processing bloom pass (threshold 1.02) catches it, and nothing else in the frame is bright enough to bloom.

export const SKY_PALETTE: SkyPalette = {
  zenith: 0x3f7fd6,
  horizon: 0xbfe0f2,
  // A hair warmer and brighter than the horizon swatch — thin band of near-
  // white air that sits right on the sea line.
  haze: 0xd7ebfb,
  // Below the horizon: the water/land haze the dome falls away to, so a gap
  // between terrain and dome never shows as a hard seam.
  nadir: 0x9fc3dc,
  sun: 0xfff3d6,
};

The TimeSystem walks these swatches through a day-night cycle. Nothing else in the scene has to change — the sun direction is a uniform, the lighting rig follows it, and the environment map for MeshPhysicalMaterial reflections is regenerated from the same dome via PMREMGenerator.

7. Vegetation is InstancedMesh + rejection sampling on a plantability grid

Pallet Town has thousands of grass tufts, dozens of trees, and a scattering of flowers. Rendering each as a Mesh would blow past the draw-call budget in the first frame. Every plant kind gets one InstancedMesh and a per-instance transform matrix.

Placement is rejection sampling against a plantability grid baked from the terrain field: a 2D grid where each cell stores 1 if the terrain slope is gentle, the soil isn’t the path, and the biome is right for the species. A candidate (x, z) is drawn from a low-discrepancy sequence, tested against the grid, and either accepted (with a jittered offset) or rejected.

The custom bit: per-instance frustum culling. InstancedMesh.computeBoundingSphere() spans every instance; if any instance is visible, the whole batch draws. That works if instances are close together, but a batch of 4,000 grass tufts covers the entire map, so the batch is always visible and the GPU vertex-shades every instance every frame — most of them behind the camera.

Vegetation.ts maintains a parallel per-instance position array and, each frame, rewrites the InstancedMesh.instanceMatrix with only the transforms that pass a manual frustum + distance test. Instances beyond the LOD radius get their scale zeroed (a matrix with scale 0 vanishes in the vertex shader). The draw count stays the same, but the vertex shader early-outs on 60–80% of tufts and the frame budget survives.

What this buys, and what it costs

Buys:

  • Boot time is bounded by JS parse, not asset download. The engine tarball is a few hundred KB gzipped. There’s no waterfall of texture fetches, no “waiting for hero.glb” spinner.
  • The world is procedurally consistent. A single seed reproduces every polygon and pixel. The WildGrass clearance mask, the vegetation plantability grid, the terrain heightfield, and the splat map are all derived from the same field object — they can’t drift out of sync.
  • Diffs are text. Rebalancing Bulbasaur’s ear placement is a two-line edit to a Ball list, reviewable in a PR. No Blender file, no re-export, no LFS.

Costs:

  • Marching cubes at resolution=48 is not free. Each starter takes ~30–80ms at boot. The engine amortizes this by baking creatures the first time they’re needed and disposing them properly on unmount.
  • Every material is onBeforeCompile-fragile. Three.js chunk names change between versions. Upgrading the three dependency is a “walk every material and re-verify the splices” exercise.
  • Debugging shaders in the browser is hard. There’s no source-mapped GLSL — you get one giant compiled string. The engine annotates each splice with a // injected: <name> comment so RenderDoc and browser DevTools shader-source view stay searchable, but it’s still worse than authoring in a shader IDE.

The trade-off is worth it for a specific case: a smallish stylized world that has to boot in the browser without a preload screen, iterated by two people, versioned in git. If you tried this for open-world realism it would collapse — the authored asset budget is what fills a AAA world with the kind of detail no procedural rule can match.

For a chapter-scale stylized game like Pokémon of the Forest: nine directories of TypeScript, no .glb, no .png, and a browser tab that opens straight into Pallet Town.

Play it: Pokémon of the Forest

— Read Next —

Recommended Dispatches

More engineering deep-dives into 3D rendering, physics simulation, and game architecture

Three.js

A four-pass post stack for a desert tank sim — bloom, FXAA, IBL from RoomEnvironment

Tank Battle's desert look isn't a shader trick, it's four passes and one PMREM step. RenderPass → UnrealBloomPass (0.28 strength, 0.8 radius, 0.85 threshold) → FXAA → OutputPass, plus a scene.environment sourced from PMREM(RoomEnvironment) at 0.04 sigma. Here's why each pass is doing what it does, why the stack falls back to plain renderer.render on touch devices, and the one FXAA-on-resize footgun that's caught me twice.

Read dispatch
Three.js

A four-anchor raycast suspension for a tank in raw Rapier

Tank Battle's chassis rides on four downward raycasts, each of which contributes a spring impulse, a track-drive impulse, and a lateral friction impulse. It's the standard raycast-vehicle pattern from Unity's Wheel Collider or BeamNG, written straight against @dimforge/rapier3d-compat with no vehicle plugin. Here's the whole loop, the constants, the front/rear drive split, and the lateral-friction cliff that makes it feel like a tank.

Read dispatch
Three.js

Sound Race — every song generates a 3-lane hazard course, decoded, analyzed, and driven entirely in the browser

How we ship a rhythm-driven WebGL racer with no backend. A Web Audio decode + a Web Worker that runs FFT off the main thread, a beatmap synthesizer that turns spectral flux into a curved elevated road, an audio-clock game loop that never drifts against the music, and a Jamendo-only music source that keeps CORS out of the failure list. All from the production code of /gamecenter/sound-race.

Read dispatch

Recibe un email cuando publiquemos un nuevo artículo — análisis técnicos, ~una vez al mes.