Cinematic rendering#
Warning
Experimental. cinematic is new in 3.0.0 and not yet on the same
footing as the other two renderers: the trait names and their defaults may
change, the image a given scene produces may change between versions, and
the coverage gaps listed below are real rather than temporary oversights
(volume_slice is not drawn, mip stays outside the light simulation).
simple and advanced remain the stable choices; please report what
breaks.
plot = k3d.plot(renderer='cinematic')
Where advanced approximates indirect light with an occlusion
pass, cinematic traces it: rays scatter off surfaces up to
cinematic_bounces times, gathering colour from the environment and from each
other. Soft shadows, mirror and glossy reflections, and colour bleeding between
nearby objects all appear without a single extra knob - they are consequences of
the simulation rather than effects layered on top of it.
The path tracer is three-gpu-pathtracer by Garrett Johnson, on his three-mesh-bvh; K3D hands it a scene and adds volumes, its own sampling loop and the controls below. The denoiser is Intel’s Open Image Denoise, run in the browser by oidn-web. Our thanks to all of them.
The image is progressive: one sample per animation frame, with a counter in the
corner, until it reaches cinematic_samples - a hard ceiling, after which the
loop stops and an idle plot costs nothing. Any change to the camera, the scene or
the lighting abandons the accumulation and starts it again from sample zero, so
what you see always describes the current state. While you drag the camera the
frame is rasterised instead (the same picture advanced would draw, minus the
occlusion pass), so the view follows the mouse; path tracing resumes the moment
the camera settles. Screenshots always render the full budget, so an exported
image is as clean as the budget allows regardless of what the interactive view
had reached.
The parameters#
plot.cinematic_samples = 64 # accumulation budget, [1, 100000]
plot.cinematic_bounces = 6 # light bounces, [1, 32]
plot.cinematic_glossy_filter = 0.25 # widen glossy lobes after a rough bounce, [0, 1]
plot.cinematic_seed = None # None: fresh noise each time; an int: repeatable
plot.cinematic_denoise = 0.0 # 0 is off, 1 the denoised image, between a mix
plot.cinematic_bokeh_size = 0.0 # aperture diameter in scene units, 0 is a pinhole
plot.cinematic_focus_distance = 0.0 # distance from the camera; 0 is as far as its target
plot.cinematic_aperture_blades = 0 # 0 is a round iris, 3 to 16 a polygonal one
cinematic_samplesHow many samples the accumulation gathers before it parks. Noise falls off as the square root of this number, so 4x the samples means half the noise: the step from 32 to 128 is plainly visible, the one from 512 to 2048 rarely is. Cost is linear in it. The ceiling is deliberately far above anything interactive, because a final render is worth waiting for - and because the loop stops there rather than burning a GPU forever.
cinematic_bouncesHow far light is followed. 1 is direct lighting only: no colour bleeding, no reflection of one object in another, and interiors go black. 6 is enough for ordinary scenes; a closed white room or a stack of glossy surfaces keeps getting brighter up to 12 or so. Cost grows with it, though sub-linearly - paths that leave the scene stop early.
cinematic_glossy_filterFirefly control, described below. 0 leaves light transport unbiased.
cinematic_bokeh_size,cinematic_focus_distance,cinematic_aperture_bladesThe lens, described below. 0 is a pinhole and leaves the image identical to the other renderers.
cinematic_seedNone, the default, draws fresh noise for every accumulation, so two renders of the same scene differ in their residual grain the way two photographs do. An integer pins the noise: the same plot then renders the same image, sample for sample, across page loads and machines - what a reference-image test suite or a frame-by-frame animation needs, and what a notebook does not. Different seeds give different, equally valid patterns.
Note
Path tracing produces high dynamic range: bounced light between bright
surfaces genuinely exceeds 1.0, and without a tone curve those values clip.
A yellow menger sponge - all cavities, all bounce - blows out about 7% of its
pixels at tone_mapping='none' and none at all with 'aces'. If a
cinematic render looks hot where advanced looked fine, reach for
plot.tone_mapping before plot.lighting.
Anti-aliasing#
plot.antialias is a rasteriser’s tool and cinematic does not use it. In the
other two renderers a screenshot is drawn up to 32 times with the camera nudged by a
fraction of a pixel and the results averaged, because a rasteriser has no other way to
get an edge sample. The tracer never enters that loop: it already sends each ray
through a random point inside the pixel, weighted by a tent filter rather than a box,
so every sample you pay for is also an anti-aliasing sample. A 64-sample render has
reconstructed its edges from 64 subpixel positions - more than the jitter table offers
at any setting, and for no extra time.
Leave it at 0. A non-zero value still asks the browser for a multisampled framebuffer,
and cinematic ends on a fullscreen quad with no geometry edge to multisample: the image
comes out the same and the buffer is not free, which at 4K is worth not paying for. The
one thing it still reaches is the rasterised axes helper composited into the corner, so
it matters only while plot.axes_helper is on.
Fireflies#
A polished surface lit by a small very bright source - metal under a sunny HDRI, typically - throws fireflies: isolated bright pixels left by the rare path that happens to reach the sun through a mirror. They fade as the square root of the sample count, which is to say hardly at all.
cinematic_glossy_filter widens a glossy lobe in proportion to the roughness
already gathered along the path. A specular seen directly is unaffected - nothing
has accumulated yet - while the path that hits a rough surface first and a mirror
second gets spread out, and the speckle with it. That is why it defaults to 0.25:
the bias is invisible where you look straight at a reflection, and it removes the
artefact where the artefact lives.
Its limit follows from the same rule. A chain of smooth surfaces accumulates almost no roughness, so a mirror floor reflecting a polished model keeps its fireflies at any setting - raising the filter does nothing there. What helps is giving one of the two surfaces some roughness, or choosing an environment whose brightest spot is less concentrated than a sun.
Rendering an animation#
Every frame is an independent accumulation - nothing is reused between them - so a sequence has a failure mode a single image does not: the brightness of the whole frame walks about slightly from one to the next, and bone and tissue appear to flicker even though the scene is changing smoothly.
The cause is the sampler, not the scene. All pixels share one stratified sequence, each
offset by its own blue-noise value, so the residual error of a frame is coherent
across it rather than independent per pixel - and with cinematic_seed left unset
that sequence is reshuffled from Math.random for every frame. Each frame therefore
carries its own small, whole-frame bias.
plot.cinematic_seed = 1 # the same sequence for every frame
plot.cinematic_denoise = 1.0
Pinning the seed removes the flicker: every frame draws the identical sequence, so the coherent part is identical too and only what you actually animate changes. On its own it trades one artefact for another - the grain is seeded from the pixel’s own coordinate, so it stops moving and sits still on the screen while the image travels underneath it. The denoiser is what makes the pair work, because it removes the grain that pinning has just made stationary.
Do not vary the seed with the frame number to avoid the stuck-grain look. That puts the flicker straight back: a different sequence per frame is exactly what caused it.
One more thing a frame loop needs. It is not specific to cinematic - it is just that
a path traced frame takes long enough to make the race easy to hit:
for i in range(frames):
set_up_frame(i) # camera, colour range, whatever moves
headless.sync(hold_until_refreshed=True) # not sync() - see below
with open('frame_%06d.png' % i, 'wb') as f:
f.write(headless.get_screenshot(True))
sync() on its own returns as soon as it has asked the page to refresh. The state
travels to the browser over an asynchronous request, so a screenshot taken immediately
afterwards renders whichever scene the page happens to be holding - sometimes the one
you just set, sometimes the previous one. The symptom is two byte-identical files in
the middle of a sequence. hold_until_refreshed=True waits for the page to confirm it
has the new state, which is what every reference image in this library uses. The rest of
that API - the drivers, the resolution, the diagnostics - is on its own page,
Headless plots.
Denoising#
Monte Carlo error falls as one over the square root of the sample count, so every halving of the grain costs four times the samples. Past a few hundred a render keeps improving and stops looking like it: the change between one budget and the next drops below what an eye can see long before the noise is gone.
plot.cinematic_denoise = 1.0 # 0 is off
cinematic_denoise runs Open Image Denoise, the
denoiser Blender’s Cycles uses, through oidn-web: a
neural network trained on path-traced images, which reads far more of the neighbourhood than a
filter can. 0 is off - the only value that leaves the image exactly as it was traced - 1 shows
the denoised image, and between them the two are mixed. The mix is not a strength: the network has
none, and 0.5 keeps half of the grain. It is for keeping some of the texture the network smooths,
which is worth it from about 0.7 up; moving the slider on a finished image only recomposes it.
It runs once, when the accumulation reaches cinematic_samples; while the samples come in the
image is shown as traced, and a moved camera starts both over. On surfaces it is guided by the
albedo and the normals of what the camera sees first, which is how it keeps the edges and the
textures; inside a volume there is no first surface, and it works from the colour alone.
It needs WebGPU, which current Chrome and Edge have. Where there is none the image is shown undenoised and the browser console says why. The network weights, 3.5 MB, ship with K3D and are read the first time a plot asks for them.
What it does to a volume. Measured on the CT heart of the gallery, 800 x 800, against the mean of two renders of 2048 samples each, in 8-bit levels of the displayed image (the reference’s own noise is 2.1):
samples |
traced |
denoised |
|---|---|---|
8 |
46.6 |
9.9 |
16 |
37.1 |
8.5 |
32 |
26.9 |
7.0 |
128 |
11.6 |
4.8 |
Sixteen samples denoised land closer to the reference than 128 traced. Fine structure that is not in the reference - invented detail - stays at the level two 2048-sample renders differ by, so nothing measurable is made up. What it does take away, at low budgets, is texture one or two pixels across: at 8 samples about half of its contrast, at 128 about a tenth. A thin vessel stays where it is; the grain of the tissue on it goes smooth. For a picture, 16 to 32 samples denoised is enough; for judging fine texture, give it 128 or more, or look at the traced image.
Depth of field#
The other two renderers see through a pinhole: everything is in focus because every
ray starts at a single point. cinematic traces a real aperture, so it can put a
plane in focus and let the rest fall away - which is a property of the lens, not of
any object in the scene, and lives on the plot for that reason.
plot.cinematic_bokeh_size = 0.4 # scene units - a CT scan 400 units wide needs a bigger one
plot.cinematic_focus_distance = 0 # 0: whatever the camera is pointed at
cinematic_bokeh_size is the diameter of the aperture in the units of your own
scene, the same units as bounds or point_size, because a plot has no idea
what a millimetre is. It is 0 by default, and that is the only value that leaves the
image exactly as the other renderers draw it - opening it beyond zero costs one shader
recompile, then nothing.
cinematic_focus_distance is how far in front of the camera the sharp plane sits.
Left at 0 it follows the camera’s own target, so the plot stays sharp where you are
looking and the focus tracks the orbit; set it explicitly to focus in front of or
behind that.
cinematic_aperture_blades shapes the iris. 0, the default, is a perfect circle;
3 to 16 give an aperture of that many sides, which is what makes an out-of-focus
highlight read as a hexagon rather than a disc. It does nothing while the aperture is
closed.
Two things follow from this being a real aperture rather than a blur applied afterwards. It costs no extra samples - the aperture is sampled along with everything else - but it does need more of them, because a defocused region is an average over a wider set of paths and converges more slowly. And it cannot be undone in post: what the lens did not resolve was never traced.
Environments are the light#
There are no light objects in cinematic. The environment map is the only
source of illumination, and it is what every reflective surface reflects, so
choosing it is the single biggest decision about how a plot looks - more than any
material parameter.
It is not the backdrop, though: behind the scene you get plot.background_color,
exactly as in the other two renderers. The environment lights the model and shows
up in its reflections; the space behind the data stays yours. A photograph of a
warehouse behind a plot would look striking and say nothing - the light it casts is
what changes how a surface reads, and that is the part worth having.
plot.environment = 'studio' # procedural preset
plot.environment = 'venice_sunset' # photographic catalog (Poly Haven, CC0)
plot.environment = my_hdr_array # any (H, W, 3) float32 equirect
plot.environment_rotation = np.pi / 3 # spin it around the scene's up axis
plot.lighting = 1.5 # exposure, not a light count
plot.tone_mapping = 'aces' # filmic curve for the highlights
Every map is energy-normalised, so the environment carries the shape of the
light while plot.lighting stays the exposure knob. Rotating it moves the
highlights without changing their intensity, which is often the quickest way to
make a specific surface read well.
The same gold dragon on a polished floor, under six environments, at 32 samples
each, denoised. Nothing changes between these images except plot.environment - so every
difference you see is the light itself and what the metal reflects of it:
import k3d.environments
k3d.environments.available()
# ['autoshop_01', 'brown_photostudio_02', 'burnt_warehouse',
# 'moonless_golf', 'venice_sunset']
Note
The photographic maps live in the Python package, so a kernel-less page
cannot resolve their names. An exported HTML snapshot therefore offers only
what it can regenerate: the procedural presets plus the map that was baked
into it at export time. A page may widen that list by including the sideload
script generated by k3d.environments.save_js(path) next to
standalone.js.
Ambient-occlusion knobs are absent from the panel here: occlusion is not approximated, it is traced.
What changes shape-for-shape#
A path tracer needs surfaces with area, so objects drawn as screen-space impostors are rebuilt as real geometry. The result keeps the shape you asked for; the differences worth knowing:
Object |
In |
|---|---|
|
Traced as they are. |
|
Merged spheres of real geometry. Sphere detail adapts to the point count
and is capped by a triangle budget, so very large clouds render coarser
spheres. |
|
Tubes of world-space width. |
|
Shafts become tubes of radius |
|
Camera-facing quads, frozen in the orientation they had when the accumulation started; they do not turn with the camera mid-frame. |
|
Unchanged: HTML overlays drawn on top of the finished frame. |
|
Traced as a participating medium: the box enters the acceleration structure as the medium boundary and rays are tracked through the 3D texture inside it. One volume per plot; further ones fall back to the raster overlay with a warning. See below. |
|
Ray-marched as in |
|
Not rendered (a warning says so). A slice paints its cut plane with its
own shader and carries no depth-segment mechanism, so it can neither be
traced nor composited correctly; use |
Unlit primitives |
Lit. A path tracer has no unlit surface, so |
The grid |
Not drawn. |
Volumes are the biggest difference between this renderer and the other two, and they have their own page: Volumes.
Requirements and failure#
cinematic needs WebGL2 with renderable float textures. When the browser
cannot provide them, switching to it fails loudly: an error overlay names
the reason and the renderer trait reverts to its previous value. There is no
silent fallback to another renderer - a plot that says cinematic is always
path traced.
Cost scales with resolution, sample budget and bounce count. On a software renderer (CI, remote sessions without a GPU) a converged frame takes seconds to minutes; the library’s own reference images use 16 samples at half resolution for exactly that reason.
Before the first sample the scene needs a ray-tracing acceleration structure, rebuilt whenever the geometry changes. Past a hundred thousand triangles that build moves to a worker - the counter reports its progress and the camera keeps responding on rasterised frames meanwhile. In a notebook the worker script comes from the kernel and nowhere else, which keeps an air-gapped deployment air-gapped; a standalone page looks for it next to the bundle it loaded, then on unpkg for that same version. When none of those answers - an unpublished build, a network without a route out - the structure is built on the main thread and the page stops responding for as long as that takes.