Dekiro Dev Blog

Part 8 of 28

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.

Town houses with PixelLook off and on

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.

Broadleaf crowns built from leaf cards

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.5 is 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.

Walking through the tall grass of the Whispering Fields

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.

The fields at noon, with cloud shadows on the ground

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.
A full frame with the lens on: soft corners and a slight colour fringe
The same fields at night: moonlight on two wolves and a boar

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."

Before and after the sprite look on monsters: the same wolf pack with pixel-texel shading (left) and sprite-style shading (right)

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.

Frostpeak's pines with the pixel-texel look and with the sprite look

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.

The current look: sprite-shaded houses and lanterns, the unchanged grass, cloud light and a sprite character at the harbor at sunset

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.