Pixel textures
A pixel texture is a texture your code paints on the CPU, texel by texel, the first time a view uses it. You declare it with definePixelTexture: its size, its pixelsPerMetre and a painter. The painter writes a colour, a height and an optional glow, and the engine derives the normal map from the height, so every pebble and blade bumps under a low sun. It is how the pixel diorama look paints its ground, its walls and its roofs at 16 texels a metre, chunky up close while the edges, the shadows and the blur stay smooth.
Unity paints a Texture2D with SetPixels, Godot an Image with set_pixel and Roblox an EditableImage, each texel by hand. None of them derives a normal map from what you painted; Unreal’s NormalFromHeightmap material function and Godot’s NoiseTexture2D come closest. A painted texture also needs no file: one set of painters and a different seed give each game its own stones and blades.
Declare a palette
Section titled “Declare a palette”definePalette declares ramps, each a list of CSS hex colours, darkest first. The palette adds one shade below each ramp’s darkest colour, at darkerShade, and one above its lightest, at lighterShade. So palette.ramps.grass holds two more steps than you declared, and palette.ramps.grass[1] is your first colour. Each step is a packed 0xRRGGBB number, the form a painter writes. A painter shades a step with palette.shade(color, amount). An amount below 0 darkens the colour and pulls its hue toward shadowHueRadians, a violet blue by default. An amount above 0 lightens it and pulls its hue toward lightHueRadians, a warm yellow. Because every texture shades the same way, the whole game reads as painted by one hand.
The shift runs in HSL, with the values that drew the pixel diorama’s reference frames. Each of the following moves with the shade’s amount:
- The hue moves
hueShift, 0.18, of the way to its target per unit of the amount, so a full shadow moves it 18% of the way. - The lightness moves
lightnessShift, 0.42, per unit. - The saturation scales by 1 plus
shadowSaturationShift, 0.15, per unit in shadow, and by 1 pluslightSaturationShift, −0.25, per unit in light. - The ends of each ramp take
darkerShade, −0.22, andlighterShade, 0.16.
definePalette checks each colour and each ramp’s order when it is declared, and throws with the ramp’s name. shade throws on an amount outside -1 to 1 and on a value that is no packed colour.
Paint a texture
Section titled “Paint a texture”definePixelTexture takes a name, a width and a height in texels, and a paint function. pixelsPerMetre is 16 unless you set it, the 2D frame’s figure. At 16, a 64-texel ground repeats every 4 metres, a 32-texel wall every 2 and a 16-texel detail every metre. A block’s 0.5-metre step is 8 texels, so coursed stone lines up across a cliff.
The painter gets the texture’s size as [width, height], four layers and a stream of random numbers:
color, sRGB packed as0xRRGGBB, black until painted.alpha, coverage from 0 to 1, opaque until painted, for a cut-out.height, in texels, 0 until painted. A pebble two texels high bumps as two texels of its side would;normalScalescales the bump.emissive, a grey mask from 0 to 1. A texture that lights no texel carries no emissive map. The material’semissivecolour gives the glow its colour.random(), the texture’s own stream from 0 up to 1.
Each layer’s setPixel(x, y, value) and readPixel(x, y) wrap a texel past an edge onto the opposite one and floor a coordinate that is no whole number. A stroke across an edge continues on the far side, so the texture tiles, and the normal map reads its neighbours across the edge, so it shows no seam at the repeat. y = 0 is the image’s top row, as in a PNG. A value that is no finite number throws, naming the layer and the texel, rather than painting black.
Paint every texel before the painter returns: the texture uploads what the painter left, and a painter that returns a promise throws.
Paint the same pixels on every page
Section titled “Paint the same pixels on every page”Draw from random(), never from Math.random. The stream starts from the game’s textureSeed and the texture’s name, so every page of a room paints the same pixels, and two textures on one seed paint apart. Set the seed once, as defineGame’s textureSeed, a whole number such as 1234.
Left out, the seed is a hash of the game’s name, so two games built from one set of painters paint two villages. Change it to paint the game anew. Outside a Game, such as on a story’s bare canvas, the seed is 0.
A painter that compares a value from Math.sin or Math.exp against a threshold can still paint a texel differently on two browsers, since their maths libraries differ in the last bit. Keep the decisions on random() and plain arithmetic.
Draw a texture from files
Section titled “Draw a texture from files”An artist’s PNGs fit the same handle. Give map, and optionally normalMap and emissiveMap, as imports of the files, instead of width, height and paint. The colour and the glow load in sRGB and the normal map linear, in three’s convention with green up. The texture’s size is its colour’s, and a normal or emissive map of another size throws when it loads.
Lay it on a surface
Section titled “Lay it on a surface”usePixelMaterial(texture) returns a Lambert material that lays the texture in world space at its pixelsPerMetre. Each face takes the world plane it is most turned to. On a top, x runs east and the image’s top row lies north. On a side, the image stands upright, and its bottom row sits on world height 0 and every whole repeat above it, so a 32-texel wall’s courses start at 0, 2 and 4 metres. A chunk’s seam and a cliff’s courses line up, because the same point in the world always shows the same texel. The material turns the normal map with the colour and takes the scene’s light and fog.
The material varies the texture per metre, so its repeat never shows. definePixelTexture’s variation names how, and usePixelMaterial’s variation overrides it for one material:
| Variation | What each metre does | For |
|---|---|---|
TextureVariation.TurnAndMirror |
Takes one of four quarter turns, mirrored or not, the default | Ground, grass, cobbles |
TextureVariation.Mirror |
Mirrors left to right or not, so rows stay rows | Planks, wood grain |
TextureVariation.None |
Shows the texture as painted | Coursed walls, roof tiles |
A metre’s border wanders a quarter metre either way in whole texels, so the patches read as the texture rather than as a grid. The material samples with the gradients from before the turn, so a border keeps its mipmap level. A mirror flips the normal map’s handedness with the colour, so a lit edge stays lit from the same side. Unreal varies a tiling texture with its texture-bombing material functions, and Roblox with MaterialPattern.Organic.
usePixelMaterial also takes the following options:
vertexColorsmultiplies in the geometry’s vertex colours, such as baked occlusion.alphaTestdrops a texel whose alpha is below it, for a cut-out.emissivecolours the texture’s glow, white unless set.
Every view that names the same texture and options shares one material, so a change to it, such as its emissiveIntensity, changes it for all of them; a different option makes a material of its own. The material suits faceted geometry that stands still, such as terrain blocks and walls. A moving mesh slides under a world-space texture, and a curved one seams where its plane changes, so use usePixelTexture there. A feature that appears once, such as a window, a door or a sign, also takes usePixelTexture on a quad of its own: a world-space wall repeats whatever it shows.
Lay it on a mesh’s UVs
Section titled “Lay it on a mesh’s UVs”usePixelTexture(texture) returns the maps under three’s own material props, so any material takes them in one spread: <meshLambertMaterial {...usePixelTexture(barrel)} />. A prop after the spread overrides it, such as emissive="#ffb04a" to colour a window’s glow.
The spread carries map, normalMap, normalScale, emissiveMap and emissive, white where an emissive map glows, since three’s black default would hide it. Every map samples nearest when magnified, so a texel stays square, and through mipmaps when minified, so the texture does not shimmer far off, at the quality level’s anisotropy. Godot calls the same choice NEAREST_WITH_MIPMAPS.
Both hooks paint on first use, share one texture per texture and seed, and free it when the last view that holds it unmounts. Image files suspend the view while they load. Call them inside Views, as the sample’s PlazaGround is: a room mounts no view, so it paints nothing. A glow that follows the hour, a window lit only at night, is the job of the point light pool’s useEmissive, which the engine does not ship yet.
What painting costs
Section titled “What painting costs”Painting runs on the main thread, once per texture and seed. The figures below were measured on 2026-10-17 in Node 24, on the maintainer’s Windows desktop, as the middle of three runs of 31 paints of each texture. The sample paints its 64-texel grass in a median of 1.9 ms and its cobbles in 3.8 ms a paint. The first paint of each, before the JavaScript engine has optimised the painter, takes 6.6 and 13.9 ms respectively. The cobbles shade every texel through the palette, whose HSL conversions are culori’s. The cobble painter searches only the nine cells round each texel for its nearest stones, a fifth of all the stones. A scene of 46 such textures paints in about half a second as it loads, an estimate from those first paints.
A cobbled square
Section titled “A cobbled square”The sample below paints a meadow and cobbles from one palette, then lays a cobbled square between two lawns, with a step of cobbles shown as painted. Its test mounts it:
import { definePalette, definePixelTexture, TextureVariation, usePixelMaterial, Views, type PixelPaintContext,} from "@spawnite/engine";
// One palette for every texture of the game: ramps dark to light, and the// defaults' blue shadows and yellow lights.export const palette = definePalette({ ramps: { grass: [ "#1f3a2c", "#2e5a34", "#4a7d3a", "#77a345", "#a9c95d", "#d6e58a", ], stone: [ "#1e1e2a", "#34364a", "#4f5468", "#727a8a", "#9aa3ad", "#c9cfd0", ], soil: ["#2a1912", "#43291b", "#5f3b24", "#7a4f30"], },});
/** Smooth noise, 0 to 1, that repeats every `period` texels, from a * lattice drawn from the texture's own stream so the seed changes it. */function createNoise(random: () => number, period: number) { const lattice = Array.from({ length: period * period }, () => random()); const at = (x: number, y: number) => lattice[ (((y % period) + period) % period) * period + (((x % period) + period) % period) ]; return (x: number, y: number) => { const left = Math.floor(x); const top = Math.floor(y); const across = x - left; const down = y - top; const upper = at(left, top) * (1 - across) + at(left + 1, top) * across; const lower = at(left, top + 1) * (1 - across) + at(left + 1, top + 1) * across; return upper * (1 - down) + lower * down; };}
/** A meadow: soft patches of light and shade a metre across, under tufts * lit along their tops and dark beneath. */export const grass = definePixelTexture({ name: "grass", width: 64, height: 64, normalScale: 0.45, paint: ({ size: [width, height], color, height: bump, random }) => { const ramp = palette.ramps.grass; const patches = createNoise(random, 4); for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) { const shade = patches((x / width) * 4, (y / height) * 4); color.setPixel( x, y, ramp[shade < 0.4 ? 3 : shade < 0.7 ? 4 : 5], ); bump.setPixel(x, y, shade); } // Tufts three to five texels wide: a lit top row, a body, and a // dark row of shade beneath them. for (let tuft = 0; tuft < 90; tuft += 1) { const left = Math.floor(random() * width); const top = Math.floor(random() * height); const wide = 3 + Math.floor(random() * 3); const light = random() < 0.35 ? 6 : 5; for (let across = 0; across < wide; across += 1) { // A ragged top: every other blade one texel taller. const tall = across % 2 === 0 ? 2 : 1; for (let up = 0; up <= tall; up += 1) { const tone = up === tall ? light : 4; color.setPixel(left + across, top + 2 - up, ramp[tone]); bump.setPixel(left + across, top + 2 - up, 1 + up * 0.5); } color.setPixel(left + across, top + 3, ramp[2]); bump.setPixel(left + across, top + 3, 0); } } },});
/** Cobbles: rounded stones a third of a metre across, each its own grey, * lit along the top and dark along the bottom, in brown grout with moss. */export const cobblestone = definePixelTexture({ name: "cobblestone", width: 64, height: 64, normalScale: 0.6, paint: paintCobbles,});
function paintCobbles({ size: [width, height], color, height: bump, random,}: PixelPaintContext) { const { stone, soil, grass: moss } = palette.ramps; // Eleven stones across, nudged off their grid, so the stones run 5 to // 7 texels wide. const across = 11; const down = 11; const cellWidth = width / across; const cellHeight = height / down; const stones = Array.from({ length: across * down }, (_, index) => ({ x: ((index % across) + 0.15 + random() * 0.7) * cellWidth, y: (Math.floor(index / across) + 0.15 + random() * 0.7) * cellHeight, tone: 4 + Math.floor(random() * 2), warmth: (random() - 0.5) * 0.14, })); const damp = createNoise(random, 4); for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) { let nearest = Infinity; let second = Infinity; let nearestStone = stones[0]; const home = { column: Math.floor(x / cellWidth), row: Math.floor(y / cellHeight), }; // The stones of this cell and the eight around it, wrapped: no // stone strays out of its cell, so the nearest two lie in them. for (let row = -1; row <= 1; row += 1) for (let column = -1; column <= 1; column += 1) { const each = stones[ ((home.row + row + down) % down) * across + ((home.column + column + across) % across) ]; // Measured the short way round the repeat. const dx = Math.abs(each.x - x - 0.5); const dy = Math.abs(each.y - y - 0.5); const distance = Math.hypot( Math.min(dx, width - dx), Math.min(dy, height - dy), ); if (distance < nearest) { second = nearest; nearest = distance; nearestStone = each; } else if (distance < second) second = distance; } const edge = second - nearest; if (edge < 1.1) { const mossy = damp((x / width) * 4, (y / height) * 4) > 0.6 && random() < 0.6; color.setPixel( x, y, mossy ? moss[3] : soil[random() < 0.5 ? 2 : 3], ); bump.setPixel(x, y, 0); continue; } // A dome lit from above: its top rows a step lighter, its // bottom rows a step darker, and the normal map lights its // sides from wherever the sun stands. const above = nearestStone.y - (y + 0.5); const rim = edge < 2; const step = rim && above < 0 ? -1 : above > 1 ? 1 : 0; const tone = Math.max(Math.min(nearestStone.tone + step, 7), 2); const speck = random() < 0.06 ? -0.08 : 0; color.setPixel( x, y, palette.shade(stone[tone], nearestStone.warmth + speck), ); bump.setPixel(x, y, Math.min(edge, 3) * 0.9); }}
/** A square of cobbles between two lawns, laid in world space: the * cobbles turn and mirror per metre, the lawn too, so neither repeat * shows. The hooks run inside `Views`, so a room paints nothing. */export function Plaza() { return ( <Views> <PlazaGround /> </Views> );}
function PlazaGround() { const lawn = usePixelMaterial(grass); const square = usePixelMaterial(cobblestone); const wall = usePixelMaterial(cobblestone, { variation: TextureVariation.None, }); return ( <> <mesh material={square} rotation-x={-Math.PI / 2}> <planeGeometry args={[8, 24]} /> </mesh> {[-8, 8].map((x) => ( <mesh key={x} material={lawn} position-x={x} rotation-x={-Math.PI / 2} > <planeGeometry args={[8, 24]} /> </mesh> ))} <mesh material={wall} position={[0, 0.5, -6]}> <boxGeometry args={[4, 1, 2]} /> </mesh> </> );}The storybook’s Engine/Graphics/Pixel texture story draws the same painters under a low warm sun, with the variation and the seed as controls.