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.




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.




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.

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


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.
Here is the same logic in the Unity HLSL.
Here is the depth-based pass applied to the test scene in Unity. It catches most silhouettes, but I ran into two problems.
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).

Now same trick as for the depth, I apply the Scharr filter to these dot product values, and the creases show up as edges.
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?

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

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.

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.
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.
This is the foundation of cel shading. The output is binary: 0 or 1, no in-between.

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

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
- Digvijaysinh Gohil. Outline Shader in Unity URP. youtube.com
- Lake, A., Marshall, C., Harris, M., Blackstein, M. (2000). Stylized Rendering Techniques for Scalable Real-Time 3D Animation. dl.acm.org
- Akenine-Möller, T., Haines, E., Hoffman, N. (2008). Real-Time Rendering, Third Edition. A K Peters.
- Wikipedia. Sobel operator. en.wikipedia.org