A photographed tile repeats, and after a while you can see the seam. These seven materials are drawn into maps when the page loads, each one starting as a height field, so the normal and the occlusion are measurements of that height and the colour was written in the same pass.
This is one HTML file on Three.js 0.186.1 and WebGPU. The bake runs in a worker, on the CPU. The spheres and cubes are MeshPhysicalNodeMaterial, so the finished maps are sampled by the node material. There is no texture pack. I use AI to write a lot of the baker. How soft the snow stays, and that the grass is drawn as blades sitting on the soil, come from 25+ years of JavaScript, game development, modding and level design.
What gets written
One generator per material. It fills a height array, and alongside it an albedo, a roughness and a specular. When that pass is done, finalize() builds the rest, and it builds them from the height, which is the rule I don't want a later edit to break.
- albedo, the colour
- normal, the slope of the height
- occlusion, roughness, metalness and specular, packed in one texture
- a height map the vertices actually move by
The packed texture is red for occlusion, green for roughness, blue for metalness, alpha for specular. Metalness stays at 0. Specular is a strength, used as specularIntensityMap, because snow and wet mud need a glint that roughness alone doesn't place. Occlusion is read from uv2, so the geometry copies its uv into uv2. Skip that and the AO map samples the corner of the texture.
const nx = -(H[IDX(x + 1, y)] - H[IDX(x - 1, y)]) * k;
const ny = -(H[IDX(x, y + 1)] - H[IDX(x, y - 1)]) * k;
That is a central difference, scaled by how tall the material is allowed to be, then packed into a tangent-space normal. Snow is the exception on a few texels: a flag marks a facet, and those get a small random tilt so they glint, while the colour stays white. A dark speck in the albedo would read as dirt. A tilted normal reads as a crystal.
Most of the look is still the height, and two of the materials are drawn on top of a noise field. Grass and leaves stamp shapes into the map with a height test, so a blade or a leaf covers whatever was underneath it. The soil is noise. The blades are a little rasteriser: a length, a width, a curve, and a loop that writes colour only where the new height wins. Sand is a warped sine, which bends the ripples as they run. Mud lowers the hollows, flattens them into a water surface, and drops the roughness there so the pool is the shiny part.
512, and the number you should leave alone
The maps in this copy are 512 on a side. I had them at 2048 while I was looking at single grains of sand, and that bake is too heavy to sit inside a page. The generators were tuned at 2048, and the stamps scale with the resolution:
const SIZE = 512;
const scale = N / 2048;
const count = Math.round(46000 * scale * scale);
N / 2048 is the line to leave alone. Blade length, blade width and how many blades get drawn all go through it, and the same scale is on the twigs and the leaves. At 512 you get the same picture with fewer texels. Point that divisor at 512 and the grass becomes a handful of enormous blades.
The height used to push vertices is its own map, DN, also 512, averaged down from the bake. At this size the average is one texel, so the vertices and the colour are the same grid. N has to be a multiple of DN. 1024 and 2048 still work, because the step stays a whole number. Drop N under 512 without dropping DN and the downsample loop samples off the grid.
On the mesh
Each material is a sphere and a cube, sharing one node material. Displacement is done on the CPU, along the vertex normal, from that height map. The relief slider runs it again. Cubes get about 0.4 of the amount, because a hard edge splits if you push both faces by the full height. Snow uses a smaller normal scale, a bit of clearcoat and a cool sheen. Mud uses a stronger clearcoat, which is the wet film. The rest are the same maps with a different roughness story.
The first load waits on the worker. Seven materials, three at a time. Drag to orbit, right-drag to pan, the wheel to zoom. Turntable is on until you grab it. The checkbox draws the maps under the view, which is the quickest way to see whether a change landed in the normal or only in the colour.
The frame is that file. The progress line is the bake. Once it clears, the relief slider is the one that moves vertices, and the normal slider only changes how hard the light reads the slope.
WebGPU only, on Three.js 0.186.1, with no WebGL fallback. If the browser has no WebGPU, the page stops. Open it through a server, because file:// will not load the modules. php -S localhost:8000 or npx serve in the folder is enough.
- Open it on its own
pbr.html, the project-
SKILL.md
If you add a material, fill the height and the colour, then let finalize() build the normal and the occlusion. A hand-painted normal will fight the displacement the moment the relief slider moves. And if you change SIZE, change nothing in the scale line.