Chasing the Valheim Look, and Leaving It for HD-2D
Pixel texels on every surface, leaf cards, grass that parts and leaves trails, shadows from clouds that don't exist, and why a day later every surface except the grass was redrawn in the style of a pixel sprite.
The target #
Valheim is a good reference for a small team. Its models are simple, but the world looks rich, because of a handful of tricks applied everywhere: low-resolution textures with hard pixels, dense foliage made of textured cards, grass you walk through, strong light and atmosphere, and a slightly soft lens. None of it needs detailed models. That made it a perfect target for a game whose every model comes from a Python script.
The human set the direction and judged every step. The AI implemented each piece as a shader or a small script, took screenshots, and iterated. Almost all of it landed in one night, in about twenty commits.
1. Pixel texels on everything #
Our models have no textures: each material is a flat colour from the Blender builders. To get Valheim's
pixel texture feel without painting textures, we generate the texels in the shader. pixel_noise.gdshaderinc
picks the plane a surface faces, cuts it into a grid of texels, and tints each texel a little lighter or
darker, warmer or cooler, in two sizes:
vec3 pixel_tint(vec3 col, vec3 pos, vec3 n, float density, float strength) {
vec3 an = abs(n);
vec2 uv = (an.y >= an.x && an.y >= an.z) ? pos.xz : ((an.x >= an.z) ? pos.zy : pos.xy);
vec2 t = uv * density;
vec2 c = floor(t);
float fine = pn_hash(c);
float coarse = pn_hash(floor(c / 4.0) + 57.0);
float v = (fine - 0.5) * 0.65 + (coarse - 0.5) * 0.35;
float texel_px = max(fwidth(t.x), fwidth(t.y)); // texels per screen pixel
float k = strength * (1.0 - smoothstep(0.3, 0.9, texel_px));
Two details make it work. The texels are computed in the model's own space, scaled to metres, so they
stick to a monster as it walks instead of swimming across it. And the effect fades out where a texel
gets smaller than a screen pixel (fwidth), so distant surfaces don't shimmer.
A small script, PixelLook, applies it after a model loads. It swaps every plain material for the pixel
shader with the same colour, roughness and glow. Materials with transparency stay untouched, and so,
after the latest commit, do painted textures: they keep their pixels and get the rest of the shader's
features (rim light, fade-in, cloud shadows).1 This is where the transparent-roof bug from the Blender post
came from: the swap originally ignored double-sided materials.
2. The ground, and a precision bug #
The ground shader mixes several noise scales into patches, dry and damp areas, grain and tufts, and paves safe zones with flagstones. For a while it had a bug that made the ground look flat, with almost none of the intended variation. The fix touched two things, how the shader got the world position and the hash function, and the comment on the hash explains the main cause:
// Arithmetic hashes on the lattice cell wrapped into a small range (no sin(): the joined world
// sits ~10 km from the origin, where sin() of the huge arguments loses all precision).
float hash(vec2 p) {
p = mod(p, 289.0);
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
The classic fract(sin(dot(p, ...)) * 43758.5453) hash is fine near the origin. Our maps sit thousands of
metres from it.2 Each map has its own slot, 5000 m apart, and the joined world sat about 10 km out. At
those coordinates the GPU's 32-bit sin() of a huge argument returns garbage that barely changes between
neighbouring cells, so the noise flattens out. Wrapping the cell into a small range first and using an
arithmetic hash fixed it. Every hash on world coordinates in our shaders now works this way.
3. Leaf cards #
The first trees had crowns made of solid low-poly blobs. Valheim-style crowns are made of many small
quads, each carrying a cut-out texture of a leaf cluster. The Blender builder now scatters those cards
through each crown, and tools/flora_textures.py draws the leaf textures as pixel art with Pillow:
Light, nearly neutral colors: the game multiplies them by each plant's tint (map color + jitter). Transparent between the leaves; the cluster thins out toward the edge so a card's square never shows.
Cards have a known weakness: their flat normals make crowns look like a pile of paper. The fix is to point each card's normal out of the crown's centre, so the crown lights like one soft volume (the commit calls them "cloud-like normals"). The follow-up commit was called "Crowns all leaves (no solid blobs), wider and fuller". Flowers, reeds, ferns and dead-tree twigs became cards too.
Foliage between the camera and your character dissolves in a dither pattern, using a global shader
uniform, focus_position, that holds where the character stands. The human asked for "solid trees, but
see in deep woods", so only leaves dissolve and trunks stay solid.
4. Grass #
Grass took the most iterations: dense curved blades, then pixel texels on the blades, then textured cards with a pixel blade texture, then tall and short patches, thin spots, thinning toward roads and leaning cards. The current shader has a few ideas worth stealing:
- Stepped gradient: the root-to-tip colour moves in steps that match the texture's pixel rows, so it reads as pixel art instead of a smooth fade.
- Backlight:
BACKLIGHT = col * 0.6 * height_k;lets the sun shine through the tips. - Distance thinning with a dither, skipped in the shadow pass (
PROJECTION_MATRIX[3][3] < 0.5is true only for the perspective camera), so grass shadows don't vanish with distance.
The part players notice most is that grass parts around characters. The shader gets a list of "pushers" (up to 64, each a position and a radius) and bends blades away from them and down:
for (int i = 0; i < pusher_count; i++) {
vec2 away = VERTEX.xz - pushers[i].xz;
float d = length(away);
float k = 1.0 - smoothstep(pushers[i].w * 0.35, pushers[i].w, d);
if (k > 0.0 && abs(VERTEX.y - pushers[i].y) < 2.5) {
push += away / max(d, 0.05) * k;
press = max(press, k);
}
}
Trails came almost for free. The script drops a trampled spot every 1.2 m behind each character and passes those spots to the shader as extra pushers whose radius shrinks over eight seconds. The grass stands back up, slowly at first.
5. Cloud shadows without clouds #
Seen from a third-person camera you rarely look at the sky, but you always see the ground. Big soft shadows drifting over the land say "clouds" more than any skybox. So there are no clouds, only their shadows: a shared shader include that every ground, grass, plant and model shader calls.
float cloud_light(vec2 xz) {
if (cloud_shade.z <= 0.0) {
return 1.0;
}
vec2 p = (xz - cloud_shade.xy) / 110.0; // clouds some 30-80 m across
float n = cl_noise(p) * 0.6 + cl_noise(p * 2.3 + 5.2) * 0.28 + cl_noise(p * 5.1 + 1.7) * 0.12;
float cut = cloud_shade.w;
float shade = smoothstep(cut - 0.06, cut + 0.06, n); // soft edges
return 1.0 - shade * cloud_shade.z;
}
MapLook moves the pattern every frame through one global uniform. About a third of the ground is in
shade at any time, 22% darker. It's off at night and in dungeons. Because it's the same function
everywhere, a shadow passes over the grass, the path and a monster at the same moment.
6. The lens, depth of field, and light #
The last layer is the camera:
- A lens shader draws over the 3D view: the picture blurs slightly toward the corners (sampling a higher mip level), darkens a little there and splits its colours at the edges. The field of view widened to 60°. Tuning took three quick follow-up commits: "only at the outer corners", "half the corner blur", "a bit more color fringing". It's a Video setting.
- Depth of field focused on the character softens the far distance. Also a toggle.
- Lighting: a 36-minute day from the wall clock, a moving sun with golden mornings and evenings, a bright silver moon with its own shadows, AgX tonemapping, bloom, SSAO and volumetric fog by quality setting.
- Rim light on monsters: a soft blue-white Fresnel edge (gold for Elites) so they read in tall grass and at night. It's an instance uniform on the shared model shader, so monsters don't need materials of their own.
- Scale: Map 1's beasts are drawn 1.8 times larger (the giant rat 2.6 times), and small monsters at least 1 m tall.
What it cost #
All of this went on top of the performance budget from the previous post. Most of it is cheap by design: the pixel texels and cloud shadows are a few hash lookups per pixel; grass and leaves are cut-out cards with no extra geometry; rim light and the fade-in are instance uniforms. The expensive parts (grass shadows, volumetric fog, SSAO) are tied to the Graphics quality setting.
Then the characters changed everything #
Everything above was in place by the morning of day four, and the world looked good. The characters didn't. The player was still a capsule, and a separate line of work was trying to replace it with a 3D character from a Blender script. It went through six drafts: flat primitives, a "Valheim hulk", proportions borrowed from a popular Korean MMO, then anime-style characters with outfits, masks and glowing eyes. The human's feedback on one of them is a fair summary of the problem: "the faces are bad, like really bad", "the hair looks also like rubber". A procedurally modelled face at that size, in a 3D world, never looked right.
The human then shared a pixel-art sprite as a reference: "This is the style of pixel art sprite I'm looking for for the character. Proportions especially are better there, same for shadings." Two approaches were tried in parallel. One drew the sprites directly as 2D pixel art, in a design tool and from code. The other kept the 3D characters and rendered them into 8-direction pixel sprites. The design-tool drawings were rejected in no uncertain terms.3 The code-drawn ones survived as a style target for the colour rules. The renders won: "the rendered 3d to 2d one look way better."
That decision is the subject of its own post. What matters here is what it did to the world: a lit, outlined pixel sprite standing next to monsters and houses with soft Valheim-style shading looked like two games glued together. The human's next instruction came shortly after the sprites went in: "now with the new 2d shader on the character I would like to have all the monsters have the same shader, also all the buildings."
7. The sprite look for everything else #
The sprites follow a strict colour rule:4 The HD-2D post has the details. every material is a ramp of a
few colours computed in the OKLCH colour space, where shadows don't just get darker but shift in hue, and
the light is quantized into hard steps. To make 3D models sit next to them, PixelLook gained a second
style, SPRITE, for monsters, Shadow stones, buildings, torii, waystones and props:
the sun in four hard bands, each a hue-shifted step of the surface's colour ramp (the sprites' rule, sprite_ramp.gdshaderinc), plus a 1-sprite-pixel outline in a darker violet-leaning shade drawn in screen space.
The shader include that builds the ramp is the same one the sprite pipeline's Python mirrors, so a red roof and a red armor plate land on the same five colours. The outline is drawn in screen space with a stencil, one sprite pixel wide rather than one screen pixel, so at 4K it matches the thickness of the outline baked into the character sheets.
A day later the same treatment reached the flora, rocks, mountains and the ground. One thing was kept on purpose: the grass. "I guess not the grass. I like the grass." The grass cards, their trails and their stepped gradient from section 4 are unchanged. Cloud shadows, the lens, depth of field and the lighting also stayed: they're camera and atmosphere, and they work for sprites as well as for models.
How the switch was made safe #
Changing the look of every surface in a game on short notice is risky, so it was built as a switch rather than a replacement. Every mesh drawn in the sprite style keeps a plain twin material, and a Video setting ("Sprite-style shading") swaps every one of them at runtime. Flora, the ground and the mountains keep both materials and swap an override. That made before/after screenshots trivial (flip the setting, take the same shot) and leaves an escape hatch if a player prefers the softer look or a machine struggles.
Two smaller fixes followed from the change. World name tags above NPCs, portals and loot used to be 3D labels, which the depth of field and the lens blurred. They're now drawn as a crisp 2D overlay with pixel spacing and overlap nudging. And a shader bug from this period became a project rule: a uniform named like a local variable in a shared include broke the sprite shader, and every character rendered as a white rectangle.5 The testing post has the story.
Was the Valheim chapter wasted? #
Less than it looks. The texel shader still draws everything when the sprite setting is off. The grass, cloud shadows, lens, lighting, depth of field, the foliage dissolve and the far-origin hash fix are all in the current game. What changed is the surface rule: from "invent texture in the shader" to "quantize light into a hue-shifted ramp and outline it". Both are cheap, and both were built so the next change of mind costs a setting and a shader, not a rebuild.
Takeaways #
- Pick a reference and steal its principles, not its assets. Pixel texels, cards and atmosphere transfer to any style of model.
- Generate texture in the shader when you have no textures. Object-space texels that fade below pixel size look like a texture without being one.
- Never hash with
sin()far from the origin. Wrap and use an arithmetic hash. - Share includes across shaders. Cloud shadows, pixel noise and the colour ramp live in one file each and are used everywhere, so every surface agrees. (Name their variables carefully.)
- One look for the whole frame. The best character art fails next to a world drawn by a different rule.
- Ship big visual changes as a switch. A runtime toggle gives you before/after shots for free and a way back.
What's next #
A world needs sound. The next post is about the music: one original track per map, composed and synthesized in Python, all quoting the same motif.