github K3D-tools/K3D-jupyter v3.0.0
3.0.0

4 hours ago

K3D-jupyter 3.0.0

Three big stories in one release: a physically based advanced renderer, an experimental path-traced cinematic renderer, and a migration to anywidget that makes the same pip install k3d work in JupyterLab, Jupyter Notebook, Google Colab and VS Code - no extensions, no rituals. The major version is for the breaking changes at the bottom: shininess is gone, show_environment is gone, Notebook 6 is no longer supported, and every existing notebook renders differently because the lighting model changed.

(2.18.1 was a small hotfix - autoplay teardown, manipulator gizmo cleanup; everything below is new since then.)

Advanced renderer

plot = k3d.plot(renderer='advanced')   # or flip it at any moment
  • Image-based lighting: the environment map is the light source. Procedural presets (neutral, studio, outdoor) plus a photographic HDRI catalog shipped with the package (Poly Haven, CC0): k3d.environments.available(). Custom (H, W, 3) float32 equirects welcome; every map is energy-normalised, plot.lighting stays the exposure knob, plot.environment_rotation spins it.
  • Ambient occlusion: a GTAO pass with spatial denoising, deterministic and seam-free at any rendering_steps. Two new knobs - plot.ao_radius (fraction of the scene diagonal, default 0.07) and plot.ao_strength (default 1.8) - because a dense point cloud and the inside of a skull want different radii. Volumes and MIPs cast and receive contact shadows through their occluder shells.
  • Physically based materials everywhere: roughness + metalness (0-1, validated, GUI sliders) on all lit objects, including volume/mip highlights and now voxels, sparse_voxels and voxels_group. GGX speculars for meshes, the points 3d impostors and volumetrics; one dominant directional light distilled from the environment keeps volumes consistent with meshes.
  • Tone mapping: plot.tone_mapping = 'agx' | 'aces' | 'none'.
  • The golden rule: the renderer changes the light, never what you asked to draw. Unlit primitives (dot/flat points, simple/thick lines, labels) look identical in both renderers.
  • The points shader '3dSpecular' folded into '3d' (the name is still accepted); the impostor AO depth pass is fixed, so analytic spheres cast and receive occlusion like real geometry.

Cinematic renderer (experimental)

plot = k3d.plot(renderer='cinematic')

Where advanced approximates indirect light with an occlusion pass, cinematic traces it. Soft shadows, mirror and glossy reflections and colour bleeding between nearby objects are consequences of the simulation rather than effects layered on top of it.

  • Progressive and interruptible: one sample per animation frame with a counter in the corner, up to cinematic_samples, after which the loop parks and an idle plot costs nothing. While you drag the camera the frame is rasterised instead - the picture advanced would draw, minus the occlusion pass - so the view follows the mouse and tracing resumes when 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.
  • Three knobs: cinematic_samples (64), cinematic_bounces (6) and cinematic_glossy_filter (0.25). The last one widens a glossy lobe in proportion to the roughness already gathered along a path, which removes fireflies where they live while leaving a specular seen directly untouched.
  • The environment is the only light source - and it is not the backdrop. Behind the scene you still get plot.background_color, exactly as in the other two renderers: the map lights the model and appears in its reflections, the space behind the data stays yours. The same catalog, rotation and exposure knobs as advanced.
  • Everything becomes real geometry, because a path tracer needs surfaces with area: points are merged spheres whose detail adapts to the cloud size, line/lines and vectors become tubes of world-space width, texture_text becomes camera-facing quads. Unlit primitives are lit here - a path tracer has no unlit surface.
  • Volumes and MIPs keep working without being path traced: the march stops at the first traced hit and composites over the accumulation, so a volume still reads correctly against traced geometry.
  • Large scenes build their acceleration structure off the main thread, so geometry big enough to stop the browser answering the OS no longer does. The worker script comes from the kernel in a notebook, which keeps an air-gapped deployment air-gapped, and from next to the bundle or unpkg for a standalone page.
  • Repeatable: with seeded sampling, N samples are a pure function of the scene - the same plot renders the same image across page loads, which is what makes the visual test suite possible.
  • Known gaps, deliberately: volume_slice is not drawn (it says so), wireframe and outlines have no representation, and there are no ambient-occlusion knobs - occlusion is traced here, not approximated.
  • Docs: a full page with the parameter reference, the firefly rule, a six-environment comparison of the same gold dragon and the reasoning behind each limitation.

Volumes compose with geometry (#277)

With plot.depth_peels >= 3 a volume interleaves correctly with meshes that intersect it: the ray march splits into segments bounded by the peel layers, so geometry inside the volume occludes and is occluded sample-accurately - in both rasterising renderers. Depth peeling itself moved to float targets (no more speckles on transparent overlaps), and in the advanced renderer AO multiplies layers and segments during composition, so gas in front of a dark mesh keeps glowing.

Colab and VS Code out of the box

The widget layer now runs on anywidget, still talking over the standardized Jupyter widgets protocol:

  • pip install k3d is the whole setup - the frontend module ships inside the wheel. No jupyter nbextension, no labextension, no enable_custom_widget_manager(), no text protocol (switch_to_text_protocol() is deprecated and warns).
  • The module is fetched from the kernel on demand and cached per page, rather than riding in the synced state of every widget: a plot's state went from ~5 MB to ~2 KB, so re-running a cell and calling display() again costs nothing.
  • Lazy frontends (Colab renders each output in its own frame) get object state relayed over the plot's comm automatically, in the .k3d binary encoding.
  • The wheel slimmed from 31 MB to ~7 MB.
  • The HTML-snapshot button works in every frontend - the kernel serves the standalone source over the comm.
  • Standalone snapshots offer only realisable environments in the GUI (the procedural presets plus the map baked in at export); a page can widen the list with a sideload written by k3d.environments.save_js(path).
  • Notebook 6 (nbextension) support ends.

Faster scene updates

Common changes now update objects in place instead of delete/recreate: morphing vertices with fixed indices, surface heights, point sizes and colours, single mesh/STL colours, visibility and more. GUI visibility toggles no longer orphan objects in the scene, and inconsistent vertices/indices updates raise instead of hanging the page.

Points rendering cost

The 3d point impostor became a physically based surface in this release, and a per-fragment price is paid again for every overlapping sprite - a million points a dozen pixels across cover the screen many times over. Two changes bring it back down. The environment terms are compiled out where there is no environment: the default simple renderer has none, recalculateLights zeroes the coefficients, and the shader was evaluating nine spherical harmonic bands and a full GGX lobe in order to multiply them by zero. And the specular lobe is now evaluated for the key light only - four lobes on a sprite a few dozen pixels across buy little. Measured on 1.5M impostor points on Apple silicon: 28 fps before, 80 after, against 96 for the same scene in 2.18.0. On a desktop NVIDIA the entire difference reads as about 10%, which is why it took a Mac to surface it. The same environment block sits inside the volume and mip ray marches, where it ran once per sample, and is gated there too.

What the new lighting costs

Lit meshes are drawn with three's physical material now, where 2.18.0 used Blinn-Phong. Per light
that is two dependent dfgLUT texture fetches and roughly twice the arithmetic, and the rig has
four directional lights, so the price is per fragment per light. It buys the same materials with the
same response in every renderer, and this is what it costs.

Every scene in the visual suite, both versions, identical workloads, the cost of one render timed
against a GPU readback rather than against vsync, on an RTX 4070:

scenes median
everything 181 1.034
lit meshes, filled 69 1.045
lit meshes, wireframe 8 1.011
everything else 104 1.031

109 scenes land within 5% of 2.18.0, 39 between 5 and 10%, 25 between 10 and 25%, two above that,
and six are faster. By object type only surface (1.100) and marching_cubes (1.092) sit clearly
above the rest; volume, mip, volume_slice and texture are at parity or faster, and the 3d
points are back to parity (1.032) after the change above. A frame that draws nothing costs what it
did (1.85 ms against 1.82), so none of this is fixed overhead, and the ratio does not drift with
scene size.

The two extremes are both volume-with-geometry scenes: a volume interleaved with a mesh through
depth peels is 2.9x faster than in 2.18.0, because the march now runs once across peel segments
instead of once per peel pass, while one scene compositing a volume with a mesh in the hybrid path is
1.63x slower and is the single case still unexplained.

advanced costs 1.56x simple over the same suite, which is the composite and occlusion passes
rather than a regression.

The numbers come from k3d/test/browser_performance, new here: it replays the visual suite's own
scenes in a browser against any two released bundles, duplicates each scene until the object's own
work dominates the frame, and reports both the frame time and a vsync-free probe. Not part of CI - it
needs a real GPU and a visible window.

File format

  • An additive version field in the .k3d binary format, with a backwards-compatibility test against msgpack-lite era snapshots.
  • msgpack-lite replaced by @msgpack/msgpack: ~2x faster encoding, byte-identical output.
  • Legacy snapshots carrying shininess are converted on load.

Fixes

  • an opaque texture is drawn opaque again: the material asked whether it had an opacity function twenty lines after substituting a default that made the answer always yes, so every texture was transparent and none of them wrote depth. That also re-arms the in-place colormap update, whose guard could never fire
  • Group is a widget again: it derives from Drawable but never called its __init__, so the widget half was never built and dropping one raised inside Widget.__del__
  • the snapshot extractor no longer backtracks over a dropped HTML file, and the error banner is built from text nodes rather than markup
  • volumetric metalness no longer blacks the body out
  • render inside an ipywidgets.HBox (#486)
  • stepped time-series playback without inter-frame blending; frame-stepping API (#458, #478)
  • widgets survive being constructed outside the document (#447)
  • NaN-separated polylines (tractograms) no longer poison the camera bounds
  • menu_visibility initial state respected
  • unchecking visible in the panel redraws immediately instead of leaving the object on screen until the next interaction
  • arrays read back out of a .k3d file are writable again: np.frombuffer inherits the mutability of what it is handed and zlib.decompress returns immutable bytes, so whether an array could be edited in place depended on the compression level it had been written with, and anything compressed raised assignment destination is read-only
  • arrays serialized out of the browser with compression_level > 0 were empty: fflate.zlibSync was handed an ArrayBuffer and returns zero bytes for anything that is not a typed array, without complaining, so voxel edits and chunked snapshots travelling browser to kernel failed at the reader with incomplete or truncated stream. Present since fflate replaced pako in 2022
  • numpy scalars are accepted wherever a float or an int is expected, and big-endian arrays (legacy VTK files) convert quietly instead of warning
  • changing depth_peels rebuilds every object, animated ones included, so opacity applies instead of the object silently keeping a material built for the other pipeline
  • in the orbit camera mode, the silent camera update a time-series camera animation performs every frame no longer asks for a redundant render
  • in cinematic, a semi-transparent object no longer walks towards opaque as samples accumulate

Dependencies and packaging

  • three 0.185.1, three-mesh-bvh 0.9.14, three-gpu-pathtracer 0.0.24, katex 0.18.4, lil-gui 0.21.0, fflate 0.8.3
  • new runtime dependency: anywidget >= 0.9.13; six removed
  • requires-python >= 3.9
  • source builds: pip install -e . needs only node/npm (no jupyterlab); the wheel build regenerates the frontend via hatch-jupyter-builder

Breaking changes and migration

  • shininess is removed. The trait raises a TraitError with the conversion formula (roughness = sqrt(2 / (shininess + 2))) instead of being silently swallowed. Legacy .k3d files convert automatically.
  • show_environment is removed (the 256x128 maps pixelated as a backdrop).
  • The lighting model changed from Phong to physically based materials: existing notebooks render differently (better) without code changes. It also costs something, measured above: 3% slower in the median across the visual suite, about 10% on surface and marching_cubes, and nothing at all on volumes, MIPs and textures.
  • auto_rendering is renamed to render_on_change. The old name read like a switch for a render loop; K3D has never had one - it draws when something changed and not otherwise - and what the flag actually gates is whether adding or updating an object draws a frame by itself. The old spelling keeps working behind a DeprecationWarning: the trait, the k3d.plot() argument, the constructor argument and setAutoRendering() in the JS API.
  • Impostor points and mesh spheres are no longer lit identically. A 3d point evaluates its specular lobe for the key light only, a mesh keeps all four. Silhouette, size and body shading are unchanged; what differs is the three faint fill highlights.
  • Notebook 6 is no longer supported.
  • Volume+mesh interleaving requires depth_peels >= 3.
  • cinematic is experimental: its trait names, defaults and the image a given scene produces may change in a later release. simple and advanced are the stable choices.

Documentation

  • A new renderers guide with live embeds: the same material sweep under both rasterising renderers, a photographic environment with tone mapping, and volume+mesh depth-peel composition.
  • A new cinematic page: the parameters, why fireflies happen and what the filter can and cannot do, and the same dragon under six environments.
  • Three new gallery showcases: Plasma wind (a five-star system with wind streamlines through a simulated volume), Curl-noise pearls (the AO knobs at bead scale) and the Stanford dragon, path traced at 256 samples.
  • Installation and frontend-loading pages rewritten for the anywidget era.

Don't miss a new K3D-jupyter release

NewReleases is sending notifications on new releases.