Stylized concept art direction for Blender: an NPR surface shader, a fourteen stage compositing chain, a sky that places its own key light, and a camera set in photographic terms.
Cymatics is a Blender add-on for stylized concept art. It does not replace your textures. It replaces the way light reads on them, then rebuilds the image on top.
Four stages, and it is the combination that makes the render:
| Stage | What it decides | Where it acts |
|---|---|---|
| Sky | where the light comes from, what sits behind the subject | world, plus a matching sun lamp |
| Surface shader | how light reads on the volume | per material |
| Image grade | what the finished image looks like | the whole render |
| Camera | what is in frame, and how much of it is sharp | the scene camera |
Interface in English and French, switchable from the panel header. Blender 5.0 or newer. Developed and tested against Blender 5.2 LTS.
Left: the raw render. Right: the same frame with the Jade Dusk look.
Ink Wash, with outline, flat bands and canvas. Storm Iron, with fog,
film grain and streak glare.
Blender, Edit > Preferences > Get Extensions > Install from Disk, then pick the zip from the releases page.
From a clone, python build.py writes the same zip into dist/.
Panel: 3D view, N sidebar, Cymatics tab.
- Select the objects.
- Click Do Everything, the large button at the top of the panel.
It chains: shader node group, sky and its matching sun lamp, the shader on the materials in scope keeping their textures, then the image grade.
Base Color, Normal, Roughness and Alpha are reconnected as they were. The original Principled is kept, only unplugged from the output, so Restore puts it back.
Two settings are worth doing next, and nothing else is urgent:
- Light Gain, in Setup. The shader reads the real intensity of your lamps, which has no upper bound. Around 2 for a sun, around 9 for weak point lights. Everything flat and bright means lower it, everything in shadow means raise it.
- Fit to Scene, in Image Grade. It measures the visible geometry from the camera and sets the mist range and the outline depth range from it. Without that calibration the mist pass reads zero or saturated, and the fog does nothing at all.
Fourteen stages, in the order the image goes through them. That is also the order of the sub-panels: what reads top to bottom is what the image goes through.
| Stage | What it does |
|---|---|
| Input | exposure, black point, white point, gamma, white balance |
| Paint Filter | anisotropic Kuwahara, the core of the painted look |
| Flat Bands | value quantization, cel shading done on the image |
| Outline | silhouettes from the depth pass, interior creases from the normal pass |
| Atmosphere | aerial perspective from the mist pass |
| Depth Blur | blur masked by distance from the focus plane |
| Color Grade | shadows, midtones, highlights, plus lift, gain and gamma |
| Palette Remap | luminance picks the color from an editable ramp |
| Glare | bloom, fog glow, streaks, ghosts, star |
| Lens | chromatic aberration and distortion |
| Vignette | tintable, with adjustable shape |
| Film Grain | animatable |
| Canvas | paper weave overlay |
| Sharpen | unsharp mask |
Everything is always built, even when off. A stage's switch is a mix factor between the image before and the image after, not a rebuild. A stage left at zero comes back without breaking anything.
Every setting lives in a named Value node, grouped in a frame per stage at the top of the group. The panel writes into it: moving a slider changes the image without rebuilding the graph.
Every step exposes a diagnostic tap. The Show Stage menu sends any intermediate step to the group output, masks included: line mask, fog factor, shadow and highlight masks. It is the shortest path to understanding a setting that appears to do nothing.
Each sub-panel carries two buttons in its header. Solo switches every other stage off, the way a mixing desk does: tuning one stage blind in the middle of thirteen others does not work. A second click brings them all back. Reset only touches that stage.
Luminance drives a color ramp. Duotone, risograph, limited palette.
Six starting palettes ship with it, and the ramp itself is editable directly in the panel.
The same fog and depth blur, restricted to the background, then to the
subject.
Any stage can be restricted to one subject. Four mask sources live in the same graph, chosen by an index so switching is immediate: cryptomatte by object name, cryptomatte by material name, object pass index, material pass index. The mask can be inverted and its edge softened.
Left: a ground that samples the same sky, so the horizon closes
instead of leaving a seam. Right: night, with moon, stars and the Milky
Way.
The sky lives in a shared node group rather than in the world, which is what lets anything else read it.
- Background: three zone gradient with an adjustable horizon height, or Blender's physical sky
- Sun and moon, each with its own direction, a disc, a glow, and a phase for the moon
- Stars placed by Voronoi with varying brightness, horizon fade, and a Milky Way band
- Clouds as a fractal noise band at an adjustable height
- Matching lamp: a sun aligned on whichever body is lit, created and updated with the sky
That last point is what ties the sky to the rest. The surface shader needs a dominant key to show anything at all: a sky that does not place its lamp leaves the render flat, and the shader gets the blame.
Sun from View places the light where you are looking from. Turn the view until you see what you want lit, then click.
Project on Ground samples the sky on selected geometry, taking the direction from the eye point to the shaded point. With nothing selected, a plane is created.
An engine mode sets all four stages at once, because none of them is enough on its own.
The World of Warcraft mode follows the client's published equation:
light = saturate(saturate(dot(-lightDir, N)) * diffuse + ambient)
color = texture * light
result = mix(color, fogColor, fogFactor)
Three consequences, and they are what makes the look:
- ambient light is additive, not multiplicative, so shadows sit on a colored floor and never reach black. This is the first thing missing when people try to imitate the game.
- there is no specular in the model pipeline. Any added shine breaks the resemblance immediately.
- fog is linear in distance and goes toward the sky color, which closes the set in and saturates the horizon.
The detail comes from the hand-painted texture, not from the shader, so the paint layer is switched off in these modes.
Modes: wow_ingame, wow_night, wow_cinematic.
Sources: wowdev.wiki/DB/Light and the Wrath shading notes from the Wowser project.
A focal length and an aperture say more than a field of view: 85 mm at f/2 is a portrait, 24 mm at f/8 is a wide shot of a set. The presets are written in those terms and set the lens, sensor, aperture, focus and output format in one go.
portrait_85, hero_50, story_35, environment_24, vista_18,
compression_135, cinema_40, game_third_person, isometric.
Frame Selection moves the camera and keeps the lens. Backing off rather than zooming is what keeps the perspective, and therefore the shot.
Nothing is stored twice: the values live on Blender's camera, where Blender expects them. The panel shows them directly.
- The diffuse term goes through a
Shader to RGB, normalized with a Reinhard curve, then thresholded. That gives a controllable light and shadow mask. - The lit zone is multiplied by
Light Tint. The shadow zone is darkened along a curve then takes hue and saturation fromShadow Tintin COLOR mode. A plain multiply would neutralize the color, COLOR mode moves it while keeping the albedo's value structure. - Two hemispheric ambient terms:
Sky Tinton upward faces,Bounce Coloron downward faces. - Fresnel rim, thresholded specular gated by the light mask, cavity darkening, shadow hatching, procedural hue and value breakup.
- Emission added after the sum, then mixed with a transparent shader
according to
Alpha.
Light Gain first, see above. Then Light Balance places the terminator,
and Hue Split sets how far shadow and light diverge in hue. That last one
is what carries the identity.
Light Wrap lets light spill past the geometric terminator without moving
the threshold, which softens a volume without having to recalibrate
Light Balance.
Rim Backlight concentrates the rim on the shadow side. At zero it wraps
the whole silhouette and reads as a cutout, at one it only appears against
the light.
Paint Amount doses the whole thing. Edge Wobble deforms the shadow and
light boundary with isotropic noise: that is what breaks the too-clean
terminator of a 3D render, and it is most of the gain. Stroke Stretch
stretches a second noise along one axis to lay a directional value weave on
the flats, which reads as brush direction.
Paint Scale sets the size of the strokes, and it is the one to understand
first: a high value gives fine strokes. The noise is in object space, so
apparent size depends on mesh scale. On a sphere of radius 1, scale 7 gives
a readable painted edge, scale 55 is invisible, scale 20 turns to grain. On
an object ten times larger, divide the scale by ten.
Hatch Amount lays hatching in the shadows, orientable with Hatch Angle.
It is confined to the shadow side of the terminator: hatching that spills
onto the light reads as a surface texture rather than as drawn shading.
The shader is a system of response to light. It needs a dominant key to show anything. A rig made of several point lights of similar power gives omnidirectional fill: no terminator, therefore no warm and cool split. The shader cannot invent a light direction the scene does not have.
import bpy
from cymatics import camera, grade, shader, sky
shader.build_group()
shader.stylize_material(bpy.data.materials["Material"], "ember_forge", "metal")
shader.set_preset("ember_forge") # push onto the whole file
sky.build_sky("golden_hour")
sky.set_sky("sun.intensity", 20.0)
sky.sync_sun_lamp()
grade.set_mist_range(9.0, 30.0)
grade.build_grade("ember_forge")
grade.set_control("outline.strength", 1.0)
grade.set_control("grade.saturation", 1.35)
grade.get_controls() # full state, by path
grade.set_output("CYM_Tap_paint") # look at one step
grade.set_output(None) # back to the finished image
camera.apply_camera("cinema_40")
camera.frame_selection(margin=1.2)Setting paths read stage.setting. The full list is in
cymatics/params.py, or in the return of
get_controls().
cymatics/
params.py declarative table of settings, the single source of truth
shader.py surface node group, palettes, materials, test bench
grade.py compositing graph construction
sky.py sky, celestial bodies, matching lamp, ground projection
camera.py shot setup in photographic terms
engines.py modes that imitate a game engine's render
properties.py scene properties, generated from the table
operators.py actions, automatic calibrations, presets on disk
panels.py interface, sub-panels generated from the table
i18n.py locales and the bridge to Blender's translations
locales/ en.json, fr.json
params.py carries the settings table. Three modules read it: grade.py
builds the graph, properties.py generates the scene properties by
introspection, panels.py draws the sliders in table order.
Adding a setting is therefore one line in the table plus its wiring in
grade.py. Neither the properties, nor the panel, nor the graph can drift
apart, because none of the three keeps a list of its own.
The compositor changed node names and socket shapes across the 5.x series.
Everything therefore goes through tolerant access: _new() tries several
types and returns None, _seti() writes into a socket or falls back to a
property, _link() returns False when the socket does not exist. A missing
node lets its stage pass the image through instead of failing the whole
build.
Three concrete examples, all met on 5.2: CompositorNodeGamma is gone in
favor of ShaderNodeGamma, whose socket is called Color and not Image.
CompositorNodeTexture no longer exists, so the noise now comes from
ShaderNodeTexNoise placed in image coordinates. And the Filter node lost
its sharpen mode, hence the unsharp mask built by hand.
No visible string is hardcoded. Everything goes through t(key), resolved
from locales/en.json and locales/fr.json.
Two translation paths coexist, and that is deliberate. t() serves the
labels the add-on draws and follows the language picked in the panel.
bpy.app.translations serves tooltips, which Blender freezes when classes
register: the dictionary is built from the same JSON files, so there is only
one source.
Adding a language means dropping an xx.json into locales/. Nothing in
the code changes.
blender --background --factory-startup --python tests/test_headless.py
blender --background --factory-startup --python tests/test_sky.py
blender --background --factory-startup --python tests/test_engine.py
blender --background --factory-startup --python tests/test_i18n.pyEach suite exits non zero on the first failure, so they double as CI steps. CI runs them against Blender 5.0 and 5.2.
GPL-3.0-or-later. See LICENSE.
