Moebius Shaders in Unity - Part 1

Inspirations and goals

Jean Giraud worked in science fiction, fantasy and western comics under the name Moebius. His graphic style was quite recognizable. Specific enough that other artists named him directly: Otomo cited him as a reference for Akira, and his concept work inspired the aesthetics of Alien, Tron and The Fifth Element. His wife said his exceptional precision came from his severe myopia.

Les voyages d'Edena
Les voyages d'Edena
Les voyages d'Edena
Les voyages d'Edena
Affiche Arzak, L'échappée belle
Affiche Arzak, L'échappée belle
Illustration pour le parfum Voyage d'Hermès
Illustration pour le parfum Voyage d'Hermès

In France he’s something of a monument, that probably explains why I took time to take an interest in his work. It’s the kind of classic you’re expected to worship, which tends to make me resistant (I’m a bit stubborn..). What actually got me there was searching for desert landscape references while drawing: so first I discovered Arzak and its beautiful alien-like panoramas. Then I came back to his work again when Sable came out in 2021. It’s a game by Shedworks that used his iconic graphic style as a direct visual reference.

SableSableSableSable

This project started from wanting to transfer this graphic style to a real-time renderer in Unity URP. You can see the result on the project page.

Pipeline

Since my goal is to make 3D look like 2D, I’m using NPR (Non-Photorealistic Rendering). Most real-time renderers try to imitate how light behaves in the real world. I go the other way: I don’t want the render to look real, it needs to look like a drawing. To get Moebius’s style, I rely on flat colors, ink-like outlines and hatched shadows.

Unity’s default lit shaders aim for realism: they blend light, shadows and reflections into smooth gradients. So all my object shaders are unlit, meaning Unity applies no lighting to them (no light probes and no global illumination). That leaves the shading to me. Each shader reads the direction of a single directional light and uses it to blend between a lit color and a shadow color (they are both exposed as inspector properties).

Those shaders only cover the colors, though. Hatching and outlines need to see the whole image (depth, normals), which an object shader can’t do, so they live in a second layer. Therefore I need to split the pipeline in two.

The first layer is the object shaders (color gradient, bubble, skydome). The second is a set of fullscreen post-process passes that run after the scene is rendered: hatching reads the scene color to place hatch lines according to brightness, and the outline pass reads the depth, normal and color buffers to draw edges on top.

Pipeline diagram: scene rendered into depth, normal, and color G-buffers, which feed into the outline and hatching post-process passes, producing the final image
Simplified pipeline: the scene is rendered into three G-buffers (depth, normals, color). The outline pass reads all three buffers to draw edges, while the hatching pass only needs the color to draw hatch lines. Both are drawn on top of the scene.

I’ll present the shaders in the order I actually built them, starting with the outlines and then the color gradient. Not the order of the pipeline, but it shows how the project grew.

Outline

Outlines are a fullscreen post-process pass. It runs after the scene is fully rendered, and detects edges by reading the depth, normal and color buffers.

The starting point was a tutorial by Digvijaysinh Gohil covering two well known detection methods: depth-based and normal-based. The two HLSL snippets below are adapted from it.

Depth-based detection

For each pixel, the depth buffer stores how far the nearest surface is from the camera. At the edge of an object two neighboring pixels can have very different values (say 2 meters on the object, 10 meters on the wall behind it). A gradient filter looks for these big differences: it compares the depths around a pixel and measures how fast they change. So a high value means an edge.

The filter used is the Scharr operator, a 3×3 convolution applied to the depth buffer. Scharr is a variant of the Sobel operator: it uses coefficients 3 and 10 instead of Sobel’s 1 and 2. As a result, it reacts more evenly to edges whatever their orientation is (horizontal, vertical or diagonal). This is called isotropy.

Gx=[−30+3−100+10−30+3]∗IG_x = \begin{bmatrix} -3 & 0 & +3 \\ -10 & 0 & +10 \\ -3 & 0 & +3 \end{bmatrix} * I
Gy=[−3−10−3000+3+10+3]∗IG_y = \begin{bmatrix} -3 & -10 & -3 \\ 0 & 0 & 0 \\ +3 & +10 & +3 \end{bmatrix} * I
∣G∣=Gx2+Gy2|G| = \sqrt{G_x^2 + G_y^2}

∣G∣|G| measures how strong the depth change is. A pixel becomes an edge if it exceeds a threshold.

The demo below shows what the depth buffer looks like (closer means brighter). I use raymarching to fake a scene, but in Unity you don’t need that, the buffer already exists.

GLSL

The first image is the test scene built in Unity. I put flat unlit colors to tell the objects apart. The second is its depth buffer.

Test scene with placeholder unlit materialsDepth buffer of the test scene, grayscale

Now I have everything I need to compute the outlines. The shader below applies the Scharr filter to the depth buffer. A pixel that passes the threshold turns white and the others stay dark.

GLSL

Here is the same logic in the Unity HLSL.

HLSL

Here is the depth-based pass applied to the test scene in Unity. It catches most silhouettes, but I ran into two problems.

Test scene with depth-based outlines only

The sphere outline is broken and jagged, and the floor is almost entirely black. Both come from the same limitation: this pass only looks at distance. Near the edge of a sphere the depth changes gradually, so the filter sometimes misses it. And on a floor seen at a grazing angle the depth changes fast from pixel to pixel even though the surface is flat, so the filter sees edges everywhere.

For now I’m fine with it, since adding normal-based detection on top will fix both.

Normal-based detection

For each pixel, the normal buffer stores a unit vector pointing perpendicular to the surface. On a flat area, neighboring pixels have nearly identical normals: their dot product is close to 1. At a crease, like the corner of a table, neighboring normals point in different directions and the dot product falls (to 0 for a right angle).

Three diagrams showing two surface patches with their normal vectors: coplanar surfaces (n1·n2 = 1), a soft crease at ~45° (n1·n2 ≈ 0.5), and a right-angle crease (n1·n2 = 0)
The dot product of two neighboring normals in three cases: a flat surface (1), a soft crease (around 0.5) and a right angle (0). The threshold sets how sharp a fold must be to get an outline.

Now same trick as for the depth, I apply the Scharr filter to these dot product values, and the creases show up as edges.

HLSL
Test scene with depth and normal-based outlines combined

That’s much better! The floor is clean, the sphere has a smooth continuous outline, and the cube shows interior edges that depth alone would have missed.

Both methods work on solid geometry. But what happens when a transparent object is in the scene?

Transparent bubble in the scene, no outline
where did the outlines go?

I realized that transparent objects don’t write to the depth or normal buffers, so both filters see nothing there. Transparent objects (glass, water) never get outlines. I could have done without them, but I wanted bubbles.

Color-based detection

The fix is to read from the color buffer instead. Even if they skip depth and normals, transparent objects do render to color. This third method looks at the four neighbors (left, right, up, down) and turns each color into a single brightness value (its luminance). Where the brightest and darkest neighbors differ a lot, there is an edge.

HLSL
Transparent bubble with outlines from color-based detection

All three passes combined, the bubble finally gets its outline.

Outline thickness and screen resolution

I want the thickness of these outlines to be a fixed number of pixels whatever the screen resolution. But the shader moves around the screen in fractions of its width and height (from 0 to 1), and a pixel is a smaller fraction at 4K than at 1080p. A fixed offset would then reach twice as many pixels at 4K. That would make the outline twice as thick! This is an easy fix: I simply divide the thickness by the screen resolution. Now 1.0 always means one pixel.

Two side-by-side diagrams comparing a raw offset vs a normalized offset at 1080p and 4K: the raw offset jumps two pixels at 4K, while the normalized offset always jumps exactly one pixel regardless of resolution
The offset is the distance to the neighbor the filter reads. A raw offset covers more pixels at higher resolutions. The normalized offset always covers exactly one pixel.

Limitations

The main limitation is thin geometry. Small objects like brackets or narrow shelves produce incomplete or missing outlines, because the depth discontinuity they create is too small for the filter to catch reliably at a given resolution.

Scene with small shelf brackets showing incomplete outlines on thin geometry

The inverted hull technique would fix this: each mesh is rendered twice, once normally, then scaled up slightly with backfaces visible, producing a solid outline that doesn’t depend on depth differences in screen space. It handles thin geometry cleanly regardless of size or distance. The tradeoff is that it only produces outer silhouettes, so interior creases and surface details would still need the screen-space pass on top of it. A hybrid approach (hull for silhouettes, screen-space for interior detail) could combine the best of both. Maybe a project for later?

Color gradient

The color gradient shader is the base layer all scene objects build on. Each object computes its own shading from a single directional light, with no light probes or global illumination. The shader maps that lighting to two colors: one for lit surfaces, one for shadow, with a controllable gradient between them.

Light mask

The starting point is the Lambert diffuse term, one of the oldest models in rendering. It states that the intensity of light on a surface is proportional to the cosine of the angle between the surface normal and the light direction, which in code is a simple dot product. A surface facing directly toward the light returns 1; edge-on returns 0; facing away goes negative, clamped to 0 since those faces receive no light.

GLSL

In a physically-based renderer, this smooth gradient is the goal. But this is not what we want. To imitate Moebius’s coloring we aim for flat areas of color with a clean boundary between light and shadow, not a photographic fade.

The simplest fix is a step function: below a threshold the surface is in shadow, above it is lit.

lightMask=step(threshold, NdotL)\text{lightMask} = \text{step}(\text{threshold},\ \text{NdotL})

This is the foundation of cel shading. The output is binary: 0 or 1, no in-between.

Test scene with celshading

The mask drives a lerp between a shadow tint and a light tint, both multiplied against a shared base color. The captures from here on already show those tints applied.

Already looks nice, and with outlines on top it could pass for a comic-book renderer. But I wanted more control than that (the shader is called Color Gradient for a reason). I wanted the option to keep a soft gradient when the material called for it, to move the shadow line wherever it needed to sit, and to control exactly how sharp or soft the transition should be.

Subtracting the threshold before clamping moves the boundary anywhere on the surface. Multiplying by a scale factor controls how fast the transition happens. A high scale compresses the ramp into a near-hard edge; a low scale spreads it into a soft gradient.

lightMask=saturate((NdotL−threshold)×scale)\text{lightMask} = \text{saturate}\bigl((\text{NdotL} - \text{threshold}) \times \text{scale}\bigr)

Both spheres below use the same threshold and colors but different scale values: the top one is soft, the bottom one approaches the hard cel-shading step.

GLSL

The last step multiplies the mask by the shadow attenuation from Unity’s shadow maps. A small custom HLSL function reads it from the main directional light:

HLSL

With this factor in, a surface in the cast shadow of another object has its mask pushed toward 0 regardless of its NdotL. The function also includes a preview stub that returns 1.0, since TransformWorldToShadowCoord is unavailable outside a real render pass.

Test scene with shadow casting and color gradient

Now each face responds to the light direction with its own tint, cast shadows land on the ground and on neighboring surfaces, and the outline pass runs on top of everything.

Shadows are still just flat tints, though. What turns them into something that reads as drawn is the hatching pass. Hatching, bubble, and skydome are covered in part 2.

Sources