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.lightingstays the exposure knob,plot.environment_rotationspins 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) andplot.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, includingvolume/miphighlights and nowvoxels,sparse_voxelsandvoxels_group. GGX speculars for meshes, thepoints3d 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/flatpoints, simple/thick lines, labels) look identical in both renderers. - The
pointsshader'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 pictureadvancedwould 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) andcinematic_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 asadvanced. - Everything becomes real geometry, because a path tracer needs surfaces with area:
pointsare merged spheres whose detail adapts to the cloud size,line/linesandvectorsbecome tubes of world-space width,texture_textbecomes 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_sliceis not drawn (it says so),wireframeandoutlineshave 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 k3dis the whole setup - the frontend module ships inside the wheel. Nojupyter nbextension, no labextension, noenable_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
.k3dbinary 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
.k3dbinary format, with a backwards-compatibility test against msgpack-lite era snapshots. msgpack-litereplaced by@msgpack/msgpack: ~2x faster encoding, byte-identical output.- Legacy snapshots carrying
shininessare converted on load.
Fixes
- an opaque
textureis 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 Groupis a widget again: it derives fromDrawablebut never called its__init__, so the widget half was never built and dropping one raised insideWidget.__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
metalnessno 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_visibilityinitial state respected- unchecking
visiblein the panel redraws immediately instead of leaving the object on screen until the next interaction - arrays read back out of a
.k3dfile are writable again:np.frombufferinherits the mutability of what it is handed andzlib.decompressreturns immutable bytes, so whether an array could be edited in place depended on the compression level it had been written with, and anything compressed raisedassignment destination is read-only - arrays serialized out of the browser with
compression_level > 0were empty:fflate.zlibSyncwas handed anArrayBufferand 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 withincomplete 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_peelsrebuilds every object, animated ones included, soopacityapplies instead of the object silently keeping a material built for the other pipeline - in the
orbitcamera 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;sixremoved 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
shininessis removed. The trait raises aTraitErrorwith the conversion formula (roughness = sqrt(2 / (shininess + 2))) instead of being silently swallowed. Legacy.k3dfiles convert automatically.show_environmentis 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
surfaceandmarching_cubes, and nothing at all on volumes, MIPs and textures. auto_renderingis renamed torender_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 aDeprecationWarning: the trait, thek3d.plot()argument, the constructor argument andsetAutoRendering()in the JS API.- Impostor points and mesh spheres are no longer lit identically. A
3dpoint 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. cinematicis experimental: its trait names, defaults and the image a given scene produces may change in a later release.simpleandadvancedare 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.