Skip to content

3.1.0

Latest

Choose a tag to compare

@artur-trzesiok artur-trzesiok released this 10 Sep 07:24
d2b7166

K3D-jupyter 3.1.0

The release that makes cinematic usable for volumetric data. A volume was the one thing the path tracer did not trace - it was ray-marched by the raster layer and pasted over the traced image, so no light passed into or out of it and none was exchanged with the geometry around it. It is a participating medium now, it has a camera lens, and it has a denoiser. Alongside that, a headless session no longer leaks a screenshot's worth of memory per frame, which is what made long animations possible at all.

Volumes are part of the light simulation

A volume's bounding box now sits in the acceleration structure as the boundary of a medium, and every ray - camera, bounced, shadow - is delta tracked through the same Data3DTexture the raster object already owns, with the same transfer function. How much matter a ray meets matches the raster exactly, so a volume tuned under advanced keeps its density here and only the lighting changes: geometry inside the gas is shadowed by it and occludes it, the gas appears in reflections, and the light scattered inside it is the environment's.

Where the density changes sharply the medium is shaded as a surface. At every collision the tracer measures the change of normalised intensity over one gradient_step and, with a probability that rises with it, treats the point as a rough dielectric facing the falling density, with the transfer-function colour and the volume's own roughness and metalness. Bone and skin in a CT get highlights, Fresnel and an orientation; soft tissue with gentle gradients stays gas, scattering forward with a Henyey-Greenstein phase function at an asymmetry of 0.85, the way measured tissue does.

plot = k3d.plot(renderer='cinematic')
plot += k3d.volume(ct, alpha_coef=15, light_scale=4.0)

light_scale is new on k3d.volume and gives the medium its own exposure, because plot.lighting lifts the whole scene while a dense volume needs more light than the geometry around it. It multiplies what reaches an event inside the medium, so every ratio in the image survives - a crevice stays as much darker than an exposed surface as it was - and only the exposure moves. It cannot rescue a pitch-black deep interior: where no light arrives there is nothing to multiply, and cinematic_bounces is what carries light in there.

samples is not used by the tracer - there is no fixed step to set, since the distance to the next collision is drawn from the density itself. Cost follows the density in front of the ray instead of the densest voxel in the box: the tracker keeps a grid of macrocells, 8 voxels a side, each holding the largest extinction any point inside it can have, and walks that grid, so a cell of air is crossed in one step. On a 512×512×1319 CT scan with an opacity function rising across its colour range, 40% of the cells hold nothing at all and the mean majorant is a ninth of the global one. A ray straight through the densest bone still saves close to half, which is the honest limit.

The coverage is one volume per plot; a second one, or one with a mask, stays on the raster overlay with a warning, and so does a volume in a context that cannot provide the fifteen texture units a traced medium needs. mip is a diagnostic view rather than a physical one and stays outside the light simulation. (#534, 7bf8a4e)

A lens

plot.cinematic_bokeh_size = 0.4      # aperture diameter in scene units; 0 is a pinhole
plot.cinematic_focus_distance = 0    # 0 focuses on whatever the camera is pointed at
plot.cinematic_aperture_blades = 0   # 0 is a round iris, 3 to 16 a polygonal one

Depth of field used to belong to the raster volume, as a per-fragment aperture loop over up to 128 samples inside its shader. It belongs to the camera now, and the tracer gets it for free: the rays it already casts start on a disc instead of a point, so a defocused frame costs no extra samples - though it wants more of them, because it averages over a wider set of paths. It is read off the camera on every frame rather than remembered, because setupCamera closes the aperture on every assignment to plot.camera, which an animation does once a frame. (#534, 7bf8a4e)

A denoiser

plot.cinematic_denoise = 2.0         # strength in noise sigmas; 0 is off

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, and past a few hundred a render keeps improving and stops looking like it. cinematic_denoise is measured in standard deviations of the noise the renderer estimates per pixel. That estimate is free: the accumulation is split by sample parity, and the spread between the two halves is the noise, so nothing extra is traced. Where a pixel has settled the filter passes it through untouched; where it has not, it averages. Worth roughly four times the samples on a volume and nothing at all on a converged render - it removes error, and a converged image has none left to remove. Around 2 removes most of the grain a moderate budget leaves; by about 4 bone in a CT starts to look waxy, because the grain and the trabecular texture under it are the same size and they leave together.

What guides it matters more than the filter. Edge-stopping on depth and normals, the usual choice, does not work inside a volume at all: there is no first surface, the first collision is a random variable, and at low sample counts those buffers are themselves noise. Accumulated opacity is used alongside the variance instead, because without it the filter cannot tell a dark part of the medium from the black behind it. Two things to know: the switch has to be on before the render, since the halves it reads fill one sample at a time, and it costs three float buffers at the resolution being filtered, which at a 4K screenshot is not free. (#534, 7bf8a4e)

Rendering an animation

Every cinematic frame is an independent accumulation, which gives a sequence a failure mode a single image does not have: 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 offset by their own blue-noise value, so a frame's residual error is coherent across it, and with cinematic_seed unset that sequence is reshuffled for every frame. Pin the seed and the coherent part becomes identical too; the grain then sits still on the screen while the image travels underneath it, which is what the denoiser is for. The pair is the recommendation, and varying the seed with the frame number is not - that puts the flicker straight back.

plot.cinematic_seed = 1
plot.cinematic_denoise = 2.0

The other thing a frame loop needs is a barrier. sync() returns as soon as it has asked the page to refresh, and the state travels over an asynchronous request, so a screenshot taken immediately afterwards renders whichever scene the page happens to be holding. The symptom is two byte-identical files in the middle of a sequence.

for i in range(frames):
    set_up_frame(i)
    headless.sync(hold_until_refreshed=True)   # not sync()

    with open('frame_%06d.png' % i, 'wb') as f:
        f.write(headless.get_screenshot(True))

Headless sessions

get_screenshot() no longer returns the image through a browser script, because every value that crosses that boundary stays in the page's V8 heap for the life of the browser and nothing releases it - not Runtime.releaseObjectGroup under any group name, not discardConsoleEntries, not a full HeapProfiler.collectGarbage. A 4K PNG is about 10 MB, so a 397-frame animation died at 4024 MB of a 4192 MB heap. The page posts the image to the HTTP server k3d_remote already runs, and toBlob rather than toDataURL keeps the blob binary, off the JS heap, and drops base64 from both ends. Measured on a 2.17 MB frame: 2.90 MB of heap growth per frame down to 0.04. (#534, 4ebb850)

get_memory() is the instrument that found it, and it stays as a public method: used, total and limit in megabytes, plus whether either number can be trusted. Neither switch it needs is assumed - without --enable-precise-memory-info Chrome answers with a frozen 10000000 bytes whatever the page allocates, and without --js-flags=--expose-gc a reading carries the last frame's garbage - so both are tested rather than guessed, and get_headless_driver(extra_args=[...]) is how you pass them.

driver = get_headless_driver(extra_args=['--enable-precise-memory-info',
                                         '--js-flags=--expose-gc'])
headless = k3d_remote(plot, driver)
headless.get_memory()
# {'used_mb': 878.1, 'total_mb': 906.5, 'limit_mb': 4192.0, 'collected': True, 'precise': True}

get_gl_info() reports what the browser is actually rendering with. A container that loses its GPU passthrough does not fail: it falls back to software rendering, every image still comes out correct, and only the clock tells you - which is a trap when the thing being measured is time. Its texture limits are not the tell, and reading them that way misleads, since the software renderer advertises 32 fragment texture units where the ANGLE/D3D11 path offers 16. A startup or a refresh that never completes now times out with the reason and whatever the browser console said, instead of hanging. (#534, a43fda8)

The sync diff fingerprints array properties instead of copying them. Every POST / used to compare each array elementwise and then deepcopy every synced property of every object, changed or not - about 1.5 GB of memory traffic per sync on a 512×512×1319 CT, once per frame of an animation. A property is remembered as its shape, dtype and a lane-summed digest of its bytes: a reduction rather than a hash, because hashing is compute bound and a sum over uint64 lanes is not, and bytes rather than values, so nan equals itself and -0.0 does not equal 0.0. 512 ms down to 67 on the author's scan. k3dRefresh also stopped fetching the diff as a charset=x-user-defined string and rebuilding the bytes one character at a time. (#534, 3ba1d8b)

A headless page also stops tracing a canvas nobody can see. renderFrame spent the whole cinematic_samples budget on it with present=false, so the result was never presented and never read - and the bill did not land where the work was, which is why it survived this long. The orphan is abandoned at the screenshot's first sampling step, but the GPU work it queued is not, and the first readPixels the screenshot meets drains it: that is the axes-helper readback in the prologue, so forty seconds could show up against "render the axes helper" on a plot with axes_helper = 0. Measured on the author's 4K frame, 88.9 s a frame down to 74.5, and the spread across frames collapses from five seconds to three tenths. (#534, 99d470b)

A session is also quieter. werkzeug logged every /ping and the session logged a line per sync, which for an animation buried whatever the cell was asked to show; warnings and errors are untouched and the rest comes back at logging.getLogger('k3d.headless').setLevel(logging.DEBUG).

Fixes

At an even sample budget the traced image was the average of every sample but the last. Upstream keeps two blend targets and swaps only its local handles, while get target() returns a fixed slot. (7bf8a4e)

The yield between tiles was a setTimeout that Chrome clamps to 4.4 ms, which alone put a floor of 45 s under a 4K 128-sample frame. A MessagePort is not clamped, and the stall guard moved to a wall clock because 5000 clamped turns happened to be exactly the 20 s a shader needs to compile. (7bf8a4e)

A color_range edit on a volume no longer rebuilds the proxy scene, the BVH and the majorant grid, and environment_rotation no longer rebuilds the environment map and the tracer's importance-sampling CDF. Both were happening once per animation frame. (7bf8a4e)

visible = False set from Python now reaches cinematic. The raster renderers draw K3DObjects directly, so taking a node out of the scene is the whole story for them; cinematic mirrors the scene into a proxy and had no way to hear about it. (#534, d7c5dc3)

screenshot_scale is the plot's own size times the scale, and nothing else. It used to multiply the canvas backing store, which carries two factors nobody asked for - the adaptive resolution Canvas.js lowers to hold minimum_fps, and the display's pixel ratio - so a file shrank because the last frames had been slow, and the same plot produced different sizes on different machines. (#534, c1096ad)

Screenshots really are seam-free at any rendering_steps, which the documentation has promised for a while. They were not: the same scene in one pass and in six differed in 1917 of 921600 pixels, by up to 146 levels, deterministically, because each chunk was given its own projection through setViewOffset and float32 clip-to-window rounding puts about 0.3% of vertical positions on a different subpixel under a different matrix. The chunks share one full-frame projection now, bounded by a scissor on the render target. (#534, e5547be)

rendering_steps is honoured in a headless plot at all - it was missing from _PLOT_PARAMS, so the value never left Python. And cinematic says what it is waiting for, rather than sitting on a frozen counter: building the acceleration structure, compiling a program, or tracing. (7bf8a4e)

Breaking changes

k3d.volume no longer accepts focal_plane, focal_length or ray_samples_count. Depth of field moved to the camera, where it belongs - it was a property of one object rather than of the camera looking at it. The replacement is plot.cinematic_bokeh_size and plot.cinematic_focus_distance with renderer='cinematic'; the raster renderers no longer offer depth of field at all.

# 3.0.x
k3d.volume(data, focal_plane=2.5, focal_length=0.1, ray_samples_count=64)

# 3.1.0
plot = k3d.plot(renderer='cinematic')
plot.cinematic_focus_distance = 2.5
plot.cinematic_bokeh_size = 0.1
plot += k3d.volume(data)

Performance

Two BSDF lobes no K3D object can light - sheen and iridescence - are compiled out of the tracer's fragment shader. No K3D object builds a MeshPhysicalMaterial, so both are zero for every material sceneProxy hands over and volume/glsl.js zeroes them again by hand; unlike clearcoat and transmission, neither is behind a runtime guard, so evalIridescence ran in every specularEval and both sheen functions in every bsdfEval, which itself runs twice per bounce, and the results were then multiplied by that zero. 7497 characters, 7.2% of the composed shader. Measured at 1280×720, 64 samples, 6 bounces, on a volume with a glossy mesh: 8.88 s a frame down to 6.74, a 24% saving with the image unchanged. A volume-only scene rarely takes the surface path and gains little there. (#534, 9927539)

The call sites come out before the chunks, because their callers sit in bsdf_functions, which the file re-inserts verbatim, and dropping the chunks alone leaves four undefined symbols. It is done with a replaceOnce helper, so an upstream edit to any of it fails loudly at construction rather than rendering something else.

On a real animation

The author's 4K sequence over a 512×512×1319 CT scan at 128 samples a frame. Before: 83 seconds a frame degrading to 109 and dying at frame 397, the heap growing 10.14 MB a frame. After the transport fix: 62 seconds a frame holding flat at 0.008 MB. And once the headless canvas stopped being traced, a frame later in the same sequence went from 88.9 seconds to 74.5 - which is the floor, because tracing 128 samples at 4K measured a constant 73.5 s across four runs and everything after it costs 0.55 s.

Documentation

cinematic.rst covers the lens, the denoiser, what to expect from a traced volume as opposed to the ray march, why plot.antialias does nothing here, and how to render an animation without flicker. renderers.rst gains a section on what you are rendering on. There is a new page on headless plots - the drivers, the resolution rule, the frame-loop barrier, the diagnostics - which is the first description k3d.headless has had, although it is how every image in this documentation is made. (5441c68)

Volumes get a page of their own. What one looks like was documented in three places and nowhere completely; volumes.rst now carries the material knobs as they apply to a medium, what a volume contributes to the occlusion pass, composing with geometry through depth peels, the traced medium moved whole out of the cinematic page, and a comparison that page could not have had: the same cardiac CT in simple, in simple with shadow='on-demand', in advanced and in cinematic, four full HD stills, each clickable at full size. renderers.rst drops from 311 lines to 215 and cinematic.rst from 510 to 438. The gallery gains the same heart as a showcase entry. (de11e77, e06c132, 87df53b)

For contributors

The suite's cinematic budget drops to 16 samples at half scale, and every cinematic reference image moves with this release.

CI did fail on this branch, and not where it looked like it would. Cinematic turned out to be the cheapest of the heavy files at 111 s a test; what failed was test_lines_simple_attribute, on chromedriver's own 300 s ceiling for a single command. That file renders a 100×100 torus fourteen times, including through the shaders that build a tube per segment, and it had no margin before this release either - 218 s a test on 3.0.3, or 73% of the limit. A 1.29x median slowdown across 24 visual files pushed it over.

That slowdown was the orphan accumulation, which is the same thing 99d470b fixed while chasing a frame time - the connection was not noticed until the suite ran without it. Two runs of the same commit agree to within 4% across 29 files, so the shift was never runner variance; and the orphan is not new in 3.1.0, because 3.0.3 gates and calls renderFrame exactly the same way. It was simply cheap there, with no traced volume, no denoiser buffers and a smaller shader behind it. Every visual test paid it, because compare() renders in all three modes.

With it gone the suite is 2.2x faster than it was on 3.0.3 - a median of 0.45 across 29 files, every one of them faster, including files with no volume in them at all - and CI runs in 1:12:45 against 4:05. N stays at 40 on its own merits: a coarser torus is a different test, and the margin is worth having. (35549d9, 51edc49)

The lint task calls eslint's Node API directly and grunt-eslint is gone - it was the last thing standing between the Gruntfile and eslint 10, because its stylish formatter colours through util.styleText, which the node this project is developed on does not have. (#534, a69e97d)

k3d/test/probe_shader_ab.py joins the five probes that came with the memory work: a fixed scene at a pinned seed and a fixed budget, so the only variable between two runs is the bundle, with the GPU's power draw printed beside the median because a run at a different clock is not comparable. Interleave A/B/A/B - rebuilding the bundle alone moves the frame time by 1.5%, which is how the clearcoat frame, worth 0.6%, was measured and then not committed. (4731857)

Four commits here fix bugs this release introduced rather than shipped, so nobody upgrading from 3.0.3 will notice them, but a reviewer counting commits will want to know why they exist. An edit from the GUI panel never reached the tracer, because changeParameter fires reload() and then OBJECT_CHANGE, and sceneProxy evicted its cache on the second - emptying it under the material fast path the first had selected; the decision moves to the module that knows the key lists. Switching the denoiser off discarded the accumulation for nothing, the filter being a read-only pass at compose time. make html failed from a clean tree, because a plot marked :screenshot: returned a snapshot string. And CodeQL was right about w == w: a failed power reading is a missing sample, so it is None now. (f72e39b, 32579dd, 9cc2f5c, e65da42)

Verified in the Docker image: 298 passed, 1 skipped, ruff and eslint clean, webpack builds.

Full Changelog: v3.0.3...v3.1.0