Painted surface
A painted register, as Firewatch, Dredge and Sea of Thieves draw, reads as broad masses of colour with soft gradients: a crown lit at its top and dark in its heart, a path flat in the sun with its grain showing under a lamp. definePaintedSurface makes a material function that paints a model this way. Pass it to InstancedModel’s, an entity’s, a detail level’s or registerModel’s material. Nothing wears it unless you pass it.
It stays three.js’s standard material, so the scene’s sun, sky, fog, shadows, ground bounce and light mask fall on it as on every lit surface. The light is smooth, with no outlines and no toon steps.
Paint a model
Section titled “Paint a model”The colour is the material’s own colour times the model’s vertex colour, times the gradient, times the map’s grain. Each is a multiply, so leave out what the model does not need:
- Vertex colour: the masses, painted in Blender and exported as the glTF
COLOR_0attribute, which three draws when the file carries it. - Gradient: a ramp from a bottom colour to a top colour between two heights, eased at both ends.
- Occlusion: the model’s baked darkness, the glTF
_OCCLUSIONattribute, 1 open and 0 shut. - Grain: the material’s own
map, a soft painted texture of 1024 pixels at most.
Define it once, at module scope. A plant that moves in the wind wears foliage outside it, so the plant sways by its _WIND weights, its shadow with it, and keeps the painted shading. The engine’s tests mount this file:
import { definePaintedSurface, foliage, InstancedModel, type InstancePlacement, type ModelMaterial,} from "@spawnite/engine";
// A stand of spruces painted in masses: each tree dark at its foot and// warm toward its top, its map's grain flat in the sun and painted under// a lantern, and every copy swaying in the wind, foliage outside the// painted surface. Defined once, at module scope: InstancedModel asks the// function once for each of the file's materials.
const spruceSurface = definePaintedSurface({ gradient: { bottomColor: "#2a2620", topColor: "#e8d8a8", bottomHeight: 0, topHeight: 2, },});
const paintedSpruce: ModelMaterial = (original) => foliage(spruceSurface(original));
const stand: InstancePlacement[] = Array.from({ length: 12 }, (_, index) => ({ position: [(index % 4) * 5, 0, -Math.floor(index / 4) * 5], turn: index,}));
export function PaintedStand() { return ( <InstancedModel model="spruce" placements={stand} material={paintedSpruce} /> );}paintedSurface is definePaintedSurface(), every option at its default. A mesh of your own wears it too: make the material once, such as paintedSurface(new MeshStandardMaterial({ vertexColors: true })) at module scope or in useMemo, and the engine handles the rest. The first frame draws it open; from the next, the mesh draws on a geometry that shares its buffers and adds the heights and the occlusion, so an edit to its vertices still shows, while the gradient keeps the heights of that first draw.
Set the gradient
Section titled “Set the gradient”gradient multiplies the colour by a ramp from a bottom colour to a top colour between two heights, eased at both ends so the band shows no line. Its fields take the colours, the heights and the space they are measured in.
Measure a tree, a rock or a home piece in GradientSpace.Model, the default: metres above the model’s foot, in its own size, so the ramp scales with each copy and reads alike on every mesh and detail level. Measure ground chunks in GradientSpace.World, so a valley floor reads darker than a ridge across every chunk. The height is read at rest, so a plant’s sway leaves its colour where it was. A mesh the engine does not draw as part of a model measures from its own lowest point, and an animated model from its origin.
The ramp multiplies, so a colour of 1 keeps the vertex colour and a three.js Color above 1, such as new Color(1.6, 1.45, 1.05), lifts a crown’s lit tips past it.
Unity’s Shader Graph builds the same ramp from its Position and Gradient nodes, and Unreal from its gradient material functions; three.js and Godot have none.
Darken by the baked occlusion
Section titled “Darken by the baked occlusion”occlusion darkens the light by the model’s _OCCLUSION, with two shares: one for the sky’s, the environment’s and the ground’s bounced light, as three’s aoMapIntensity, and one for the direct light of the sun, the moon and the lamps.
The direct share is the painted surface’s own. Unreal’s and Godot’s material occlusion leave direct light alone, and Unity URP’s ambient occlusion darkens a quarter of it. A quarter of it keeps a crown’s heart dark at Low, where no shadow map draws. Lower it where a model’s shadow already darkens its heart at every level you ship.
occlusion: false reads none. A mesh with no _OCCLUSION draws open. On a leaf that foliage dresses, foliage darkens the sky’s light by the same attribute, and the painted surface adds only the direct share, so no leaf darkens twice. A material with its own aoMap multiplies the two.
Colour the sun’s light
Section titled “Colour the sun’s light”A painter keeps a bush one dark blue-green body in shade and lays yellow-green only where the sun falls on it. A lit material has one colour for every light, so a dark albedo turns a lighter teal in the sun, never yellow, and a yellow one stays yellow in shade.
sunlit gives the sun’s direct light a colour of its own to find on the surface. Its fields take the colour, an albedo converted like the material’s own colour, and its share, how far the surface’s colour moves toward it in the sun, 1 when left out. Undergrowth might take definePaintedSurface({ sunlit: { color: "#8a9a2c", share: 0.8 } }).
Only the sun’s light reads it: the daylight rig’s sun light, and a SunLight of your own. While the rig carries the moon, no SunLight shows it. The moon the rig carries on the same light at night, a DirectionalLight of your own, the lamps, the sky, the ground’s bounce and the light mask’s fill all keep the surface’s own colour. The sun’s colour still lights it, so it warms at a low sun. It changes the diffuse light alone: a metal’s reflection, a sheen and a clear coat keep their own colour. A leaf’s light through from behind, which foliage adds, takes the sunlit colour too.
The sunlit colour fades where the baked occlusion closes the surface, so a crown’s heart keeps its own colour where no shadow map draws, as at Low. occlusion: false leaves it whole everywhere the sun reaches. At a share of 1 the sun’s colour replaces the vertex colour, the gradient and the grain in the sun; below it they show through.
One sunlit colour serves one material. A model whose bark and leaves share a material colours both; give your model function a branch by the material’s name for a second one, such as original.name === "bark" ? bark(original) : leaves(original). In development, a sunlit surface in a scene lit by a DirectionalLight and no sun light warns once, naming the fix.
The Unity Toon Shader and VRM’s MToon split one light by its angle into a lit and a shade colour; Godot’s light() shader function writes the same by hand for each light, and Unreal and three.js ship none. The painted surface colours the sun alone, since the lamps and the sky must keep the surface’s own colour.
Show the grain under a lamp
Section titled “Show the grain under a lamp”A painted grain reads as noise in full sun and in the distance, and as hand-painted detail in a lantern’s pool. The painted surface measures the map’s fine detail against its own blur, about 16 texels across, so the map’s colour masses stay and only its grain changes. By default the sun, the moon and the sky show 0.3 of the grain, and point, spot and rectangular lights and the light mask’s fill light show all of it. The grain flattens with distance from the camera. Its fields set each share and the fade’s two distances.
A ground material takes the same grain, with the same fields, as Ground maps, grass blades and foliage says. Raise the sun’s share where a near path should keep its strokes by day. For a top-down or isometric camera that stands 30 m or more away, push the fade out, or the whole frame draws flat. grain: false draws the map as three does.
The grain needs the map’s mipmaps, which a glTF texture and three’s default filtering have; a map without them is refused in development, naming it. Alpha is the map’s own, so a cut stays where it was. On a metal, the map’s share of the specular colour follows the sun’s share. No other engine ships this: it is how the register keeps detail where the light is.
Prepare a model for it
Section titled “Prepare a model for it”Export each model with:
- Smooth normals, such as Blender’s Shade Smooth. A mesh with no normals loads flat shaded, and the painted surface refuses it in development, naming the fix.
COLOR_0for its masses of colour, and_OCCLUSIONfor its baked darkness, with the glTF exporter’s Attributes option on for the second._WIND, where it moves, as foliage’s weights say.- A matte finish: metallic 0 and roughness near 0.9. glTF’s metallic factor is 1 when a file leaves it out.
Dress a model with the painted surface once, and before foliage: foliage(painted(original)). Development refuses the other order and a second painted surface, naming the fix. A top layer, such as moss, composes on either side. Under a WebGPURenderer the painted surface is off, and the console names it once.
What it costs
Section titled “What it costs”The painted surface costs a few shader operations a pixel and one more texture read where it has a map, and one vertex attribute of 12 bytes a vertex, written once for each mesh. It draws no extra pass and binds no extra texture. Each painted material compiles programs of its own, so a scene’s loading screen links a few more: 110 against 100 in the register test with its stand painted.
It was measured on 2026-10-18 in Last Light’s register test at night, every tree of the stand painted against none, on an RTX 4070 SUPER at 30 frames a second, with the machine 98 to 100% busy with other work. A GPU frame’s mean took 16.4 ms painted and 13.7 ms plain at High over four alternating runs each, and 12.6 and 13.4 ms at Low over two. Single runs of the plain scene spread from 9.7 to 17.2 ms, so the difference is inside the noise of a busy machine. Measure it again on a quiet one before you count on it either way.
A sunlit colour adds one mix a pixel and no texture read. In Last Light’s forest edge at Low, the masses and ferns took 10.4 ms of GPU time a frame with it and 10.6 ms without it, inside the runs’ spread. That is three runs with it and two without, alternating and uncapped, on an RTX 4070 SUPER on 2026-10-18, the machine 33 to 60% busy.