Polyfork BrowseKitsPricingBlog

We put six shader looks on every model. Here is how each one works.

Palette, toon, outline, dither, pixelate and PS1 wobble, on any model in the catalogue and free to try. This is how each effect is built in Three.js, and which of them can end up in your .glb.

The same low-poly vending machine rendered four ways: its own colours, reduced to four Game Boy greens, toon-shaded with a black outline, and pixelated

Every model on Polyfork can now be restyled from its own page. Six looks (palette reduction, toon and flat shading, outline, dithering, pixelation and PS1 wobble), free to try on all 512 published models, with no account. See them side by side on the Three.js shaders page, or open any model and use the column to the right of the viewer.

The rest of this post is how each one is built, because none of it is secret and most of it is a good exercise.

"Add a shader" is three different jobs in Three.js, and picking the wrong one is most of the difficulty. You can patch a material, so the effect belongs to one model and rides along with it. You can add a full-frame pass with EffectComposer, which styles the whole picture including the background. Or you can change a renderer setting, which is not a shader at all but often gets you what you wanted more cheaply.

Below is which of those three each look is, and a question most tutorials never raise: whether any of it can end up in the file you hand to Unity or Blender.

First: can a shader be saved?

No, and it is worth being clear about why, because it decides your whole approach.

glTF describes geometry, materials and animation. There is nowhere in the format to put a fragment program. So a dither, an outline pass or a pixelation setting cannot travel in a .glb — they exist while your page is drawing and nowhere else.

What can travel is anything a shader would have computed that you bake into the file first. Vertex colours are the obvious one, and material flags are the other. Reduce a model's palette by rewriting its COLOR_0 attribute and the reduction is in the file. Switch it to an unlit material and glTF has a standard extension for exactly that, KHR_materials_unlit.

So of the six looks below, two survive an export and four do not. If your target is a game engine rather than a web page, that distinction is the first thing to check.

Palette reduction

The simplest of the six, and one of the two that exports, because it never touches a shader at all: rewrite the COLOR_0 attribute so every vertex takes the nearest colour from a small set.

The only real decision is what "nearest" means. Do it by Euclidean distance in RGB and you will get matches that are arithmetically closest and visually wrong, because RGB distance has almost nothing to do with perceived difference. Convert to a perceptual space first — OKLab is compact enough to write inline and behaves well:

const l = Math.cbrt(0.4122214708*r + 0.5363325363*g + 0.0514459929*b);
const m = Math.cbrt(0.2119034982*r + 0.6806995451*g + 0.1073969566*b);
const s = Math.cbrt(0.0883024619*r + 0.2817188376*g + 0.6299787005*b);
const L = 0.2104542553*l + 0.7936177850*m - 0.0040720468*s;   // then A, B

Match in that space and pick your palette honestly: a fixed hardware palette will mangle anything it was not designed for. The Game Boy's four greens contain no red at all, so a red object has nowhere to go. Reducing a model to fewer of its own colours is usually the more useful operation.

Toon and unlit shading

Cel shading quantises the lighting term into a few hard bands instead of a smooth ramp. Three.js ships MeshToonMaterial for this, with an optional gradient map controlling the steps.

The caveat nobody mentions: it does very little on flat-shaded geometry. If your material has flatShading: true, every face already renders as one flat tone, so banding a mosaic that is already a mosaic mostly just removes the small tonal differences that were making the form readable.

On that kind of model the effect people actually want comes from going further, not halfway. Drop the lighting model entirely so each face shows its pure albedo:

const unlit = new THREE.MeshBasicMaterial({ vertexColors: true });

That is one line, it costs less than what it replaced, and GLTFExporter writes it as KHR_materials_unlit, so it opens correctly elsewhere. Add an outline and you have what most people mean by cel shading. On flat-shaded low-poly, the outline is doing the work, not the bands.

Outlines with an inverted hull

The standard trick: duplicate the mesh, push every vertex out along its normal, and draw only the back faces. The copy shows where it sticks out past the silhouette and is hidden everywhere else.

const hull = new THREE.Mesh(geometry.clone(), new THREE.MeshBasicMaterial({
  color: 0x14181d, side: THREE.BackSide,
}));

Two things decide whether it looks right.

Use smoothed normals for the extrusion. Hard-edged models carry a different normal per face, so at a cube corner three faces push three different directions and the hull tears open with a gap at every corner. Accumulate one normal per unique position first and extrude along that, even if you keep flat shading for the visible model.

Put the width in pixels. An offset in world units is invisible on a large model and swallows a small one, and a fraction of the model's size has the same problem in reverse: what actually sets a good outline is how big the thing looks. Offset in view space instead, scaled by depth:

// in the vertex shader, after gl_Position exists
float d = 2.0 * uPixels * max(-mvPosition.z, 1e-4)
        / max(uViewportH * projectionMatrix[1][1], 1e-6);
mvPosition.xyz += hullNormal * d;
gl_Position = projectionMatrix * mvPosition;

Depth-independent on screen, and with no field-of-view constant to keep in sync, because projectionMatrix[1][1] is 1 / tan(fov / 2).

Ordered dithering

Dithering trades spatial resolution for colour resolution: quantise each pixel to a few levels per channel, and offset the threshold with a screen-space pattern so the average stays correct even though no single pixel is.

You do not need a post-processing pass. Patch the material's fragment shader at #include <dithering_fragment>, which is the last chunk in both the basic and standard shaders and sits after the colour-space conversion — so you are working on the final sRGB value, which is where dithering belongs. Dither in linear space and the steps bunch up in the shadows.

float t = bayer8(mod(gl_FragCoord.xy, 8.0));
gl_FragColor.rgb = clamp(floor(gl_FragColor.rgb * LEVELS + t) / LEVELS, 0.0, 1.0);

Two practical notes. Keep LEVELS at four or more: at two, every pixel lands on a corner of the RGB cube, so a brown surface dithers between red and olive — the average is still right but no pixel is a colour your model uses, and it reads as damage. And dithering only looks deliberate once the cells are big enough to see, which usually means pairing it with the next one.

Pixelation, without a post-processing stack

The tempting approach is a render target and a downsample pass. You rarely need it. Draw into a smaller buffer and let the browser scale it back up:

renderer.setSize(w / 4, h / 4, false);      // false: leave the CSS size alone
renderer.domElement.style.imageRendering = 'pixelated';

Keep the factor an integer so whole pixels land on whole pixels, and set camera.aspect from the display box rather than the buffer. This is the one effect that makes rendering cheaper: a quarter-scale buffer is a sixteenth of the fragments.

PS1 vertex snapping

The console had no sub-pixel precision in its rasteriser, so projected vertices landed on a coarse grid and geometry visibly swam as the camera moved. Reproducing it means snapping in clip space, after the projection:

if (gl_Position.w > 0.0) {
  vec2 grid = vec2(SNAP * aspect, SNAP);
  gl_Position.xy = floor(gl_Position.xy / gl_Position.w * grid + 0.5) / grid * gl_Position.w;
}

Snapping the mesh once in world space instead gives you a permanently mangled model rather than the wobble, because the wobble is the projection changing. Keep the grid coarse but not too coarse: below roughly 64 cells, models with coplanar surfaces start tearing themselves apart, which is authentic to the hardware and not usually what you want.

Patching a material safely

Four of the six above are material patches, done through onBeforeCompile. Two habits make that survivable:

material.onBeforeCompile = (shader) => {
  shader.fragmentShader = shader.fragmentShader.replace(
    '#include <dithering_fragment>', myReplacement);
};
// Without this every instance compiles its own program.
material.customProgramCacheKey = () => 'my-dither-4';

And if you are chaining onto a material that already has a patch, call the previous onBeforeCompile first rather than replacing it. Assets often arrive with one already installed — ours carry wind sway that way — and overwriting it silently removes a feature nobody will connect to your change.

Trying them

Every model in our catalogue can be restyled from its own page, free and with no account, on all 512 published models. The two that bake into a file can also be requested directly:

/cdn/{id}-remix.glb?look=palette:gameboy,shading:toon

which returns a real .glb with the palette rewritten and an unlit material, ready for Unity, Godot or Blender.

Polyfork

Sign in

One account for your purchases and downloads.

Continue with Google
or

No password needed: the link signs you in directly.