2026-08-21 #425
markaren
announced in
Announcements
2026-08-21
#425
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
211 commits since
2026-08-09.Tagging
2026-08-21publishes2026.08.21to PyPI.The short version: splats stopped being only a backdrop, particles became a first-class
GPU object, an external CUDA producer can write threepp's vertex buffers in place, and the
bit-exact-replay claim the sensor suite has been making is now measured rather than asserted.
Performance figures below are quoted from the commits that measured them, on the hardware those
commits name. They are reported, not re-benchmarked for this release.
Read this section before upgrading — three of these break at compile time and two change what
existing scenes produce.
createRendereris gone from the librarythreepp::createRendererandthreepp/renderers/RendererFactory.hppwere removed (acd5657b).Its no-preference overload printed a console menu and blocked on
std::cin— a demo convenience,not API.
threepp/threepp.hppno longer includes it, so this breaks every program that onlyincluded the umbrella header, whether or not it named the factory. It survives only as
examples/libs/renderer_factory.hpp, which is not installed.doc/getting_started.mdwas rewritten accordingly.The editor document model left the installed include tree
37 public headers (all of
threepp/extras/editor/*, plusdetail/ConfigCodec.hpp) moved out ofinclude/into the new static librarythreepp_editor_core(08d0b2a1). The include prefix isunchanged —
threepp/extras/editor/SceneDocument.hppstill names the same file — but the targethas no install rule, so an installed-package consumer cannot reach it by adding a header path;
they must consume the source tree and link
threepp_editor_core.Rationale: several of those headers change meaning under macros (PhysX, V-HACD) that the
installed library cannot own, so
install(DIRECTORY include/)was shipping all 49 of themcompiled to their most-disabled variant.
The same seed no longer produces the same content
Eighteen private
mt19937and hand-rolled generators were replaced by one seedablethreepp::math::Rng(splitmix64, order-independentfork(stream), a(seed, slot, stream)counter mode, Fisher-Yates shuffle) —
c1e98ea1,e452b52d. Migrated: trees, terrain, textures,grains, the ocean spectrum, the blue-noise tile, flocks, fire, splats, the perch bake.
The motive is that
std::uniform_real_distributionandstd::shuffleare free to differ betweenstandard libraries, so "same seed" never meant "same bytes" across platforms. It does now — but
any golden image, saved procedural scene or recorded dataset regenerated after upgrading will
differ from one generated before it. Sensors keep their own generators; those are unaffected.
The Vulkan mesh path now honours
BufferGeometry::drawRangePreviously only the line/points overlay did, so a Vulkan mesh always drew, copied and traced its
full buffer capacity (
43f6ed12). The indirect build now clamps each draw to the range (base LODlevel only) and carries the start through
firstVertex— exactly GL's semantics.This changes what existing scenes draw if they set a draw range and relied on Vulkan ignoring
it. For interop producers the old degenerate-tail contract is dead: unused capacity now costs
VRAM but no bandwidth.
Smaller behaviour changes
LidarReturngrew a field —int32_t returnKind(withenum class LidarReturnKind { Surface, VolumeScatter }). Source-compatible for member access, but the struct's size andlayout changed: relevant if you binary-log returns or brace-init without designators.
Meshgained two virtuals —raycastBoundingSphere()/raycastBoundingBox(). An ABIchange to a widely derived class. Consequently
SkinnedMeshpicking now uses posed-skeletonbounds instead of bind-pose bounds (
68287ffd), so raycast results against skinned mesheschange — they were previously unpickable in any pose but the bind pose.
EffectComposerintermediates default to half-float, notUnsignedByte(60610e28).2× memory on every composer target, and readbacks from those targets are now half-float. The
commit measures the reason: on a 64-pixel dark ramp, 11 distinct output levels with byte
targets against 47 with half-float.
TerrainParams::aoStrengthdefaults to 0, was 0.5 (ca5970be) — terrain built fromdefaults now renders without baked AO.
PbdParticlesraises speculative CCD by default — grains were falling through a 0.4 m floor.Canvas::showWindow()is new (
82929327). Anyone driving a Vulkan canvas without going throughrender()seesdifferent behaviour.
THREEPP_WITH_VULKANis now PUBLIC on thethreepptarget, andconfig.cmake.ingainedfind_dependency(Vulkan)(b2a5d0d7). Consumers that restated the macro can stop; consumersthat didn't were silently compiling the OpenGL-only variant of twenty public headers, which
violated the ODR when two targets in one binary disagreed.
threepp_datais fetched by URL, not git clone (744a30aa). A mirror or offline cachekeyed on the git clone will miss;
FETCHCONTENT_SOURCE_DIR_THREEPP_DATAis unaffected.tps_shooterandNorwayDrive, andFPS/fps_ragdoll.hppis gone withFPSreduced to a shooting range (2994c48a,aec9714b). The demos no longer need a Mixamo character.Splats grow up
Gaussian splats were previously "a backdrop, not a simulation asset". That is now only half true,
and the README limits section has been rewritten to say exactly which half.
splats::bakeSurface(903e36c3,0240b40b,8f2dca05) renders a cloud from a pose set,reads the median-depth AOV, fuses it into a block-sparse TSDF and marches out a triangle mesh.
One bake, three consumers:
PhysxWorld::addStaticTrimesh, a lidar target, a depth-sensor target— the physics side took no new code. A robot can stand on a scan.
Measured at 5 cm voxels: a ball dropped on a baked plane rests 3.6 mm off; 9 of 9 lidar
beams return it at 3.4 mm range error. Those figures mean nothing without the voxel size.
splats::makeSensorMeshputsthe mesh on
VulkanRenderer::kSensorOnlyLayer(31), and the scene mastersetSensorOnlySurfaces(bool)is off by default — a scene that never opts in senses andrenders exactly as it did before the feature existed.
b09a81a8,c8f4b889) — strictly, a stand-in does.Each cloud bakes an rgba16f density/radiance volume (≤128³, ~13 MB) that the water, glass and
glossy reflection legs march. Primary view only;
THREEPP_VK_SPLATVOL_OFF=1restores the oldbehaviour,
THREEPP_VK_SPLATVOL_RESandTHREEPP_VK_SPLATVOL_SIGMAtune it. Cost: +0.028 msmedian on the deferred shade. Transmission legs — a splat seen through glass — deliberately
do not march it yet.
The tile rasterizer composites the cloud after the deferred shade, so no traced leg can see
the real splats even in principle. This is why it is a baked stand-in.
1b5d509e) —SplatDepthMode{Off, Expected, Median}, which is what makes the surface bake tractable.(
0e235b67), and a scanned floor is something you can stand on (8f2dca05).eb0fea99,32825200); a secondary viewrasterizes the cloud when it asks (
952bbd1a); baking from inside a room works (ee1775c3).Still true, and deliberately so: splats go into no acceleration structure, so they cast no
shadows and contribute nothing to GI, and neither
SplatCloudnorParticleFieldis serializedinto a scene document.
ParticleField— a new Vulkan scene objectA particle type whose count the CPU never walks (phases F0–F6,
4a050ca7…278c84f7).Vulkan only; on OpenGL it degrades to an inert placeholder geometry rather than crashing,
the same way
Oceandoes.Three ownership modes —
HostRing(CPU submits into a ring of host-visible buffers),Renderer(a compute pass writes positions from a closed-form emitter; nothing crosses the bus),and
Interop(a foreign device API owns them, e.g. GPU PhysX).Three live representations —
MeshRepr(instanced geometry in one draw),BillboardRepr(lit, glowing, fogged, distance-LOD), and
DensityRepr(marched per pixel, so it participates infog, receives light and is visible to lidar).
TracedRepris stored and consumed by nothing yet.Reported numbers: 500k grains as one draw went from 8–9 fps to 41–43 fps (
f1115c60; thecommit body doesn't name the machine, unlike the rest of the benchmarks here). At 300k particles
the
Renderermode's host cost is 0.0020 ms — the same as at 10k — with GPU cost 0.139 ms(emit 0.0120 + density scatter 0.1268).
Also shipped:
FireEffect, a campfire/flame preset over the density representation withblackbody emission; emitter
FOLLOW/WRAPmodes for weather that surrounds the camera; snowthat rests and rain that splashes; and demos
vulkan_fire,vulkan_snow,vulkan_fjord,vulkan_lidar --dust,granular_conveyor.Lidar sees the dust (
5c39b8fa) — a medium is not geometry, and--dustgives you a pairedclean trace to measure the degradation against (
LidarDegradation, C++ only; Python can readreturn_kindbut cannot arm the paired trace).Zero-copy CUDA interop
An external GPU producer can write a mesh's vertex buffers in place. NVIDIA only by
construction, and the POSIX/
_OPAQUE_FDbranch is marked in-source as written but untested —no CI job has both Vulkan and CUDA.
VulkanRenderer::enableVertexInterop(mesh, deviceCopy, validate)returns aVertexInteropHandle; the renderer exports position and normal buffers as dedicated externalmemory, runs your copy once per frame inside
render(), then copies into the buffers everythingalready reads and refits the BLAS. Every failure path is a null handle, never a throw.
Also
enableParticleFieldInterop, andPbdParticles::Group::registerVulkanMemory/copyPositionsToVulkanfor the PhysX→Vulkan device-to-device path.threepp.cuda_interop.VkInteropArrayimports the allocation through the CUDAdriver API via raw ctypes (no
cuda-pythondependency — "the whole surface is four driver entrypoints"), and guards the things that fail silently: primary-context binding, pointer-context
match, range size.
GLRenderer.gl_buffer_id()exposes the OpenGL equivalent — the C++ accessoralready existed, the Python reach is what's new.
warp_fluid.py(position-based fluids,wp.HashGrid, GPUmarching-cubes surfacing, drawn as refracting water) and
warp_nebula.py(18M additive pointsby default). Both degrade cleanly on an older threepp or without CUDA rather than exploding.
warp_nebulawent from 19 to 51 fps across3affa475/cf60edcb.warp_fluid.py --obstacle <model>(6220a802) — collide the fluid against an arbitrarymesh instead of the built-in box, via a signed distance field baked once at startup. Takes
anything
ModelLoaderreads (.stl,.obj,.gltf/.glb,.dae); a STEP/IGES solid has notriangles of its own, so tessellate it first. Multi-part assemblies arrive assembled, the mesh
is auto-fitted (CAD is in millimetres as often as metres), and it sweeps like the box did.
Measured at 337k particles on an RTX 4060 laptop: sim 29.7 ms against the box's 30.8 — an
arbitrary mesh collides for the price of the block it replaced.
The bake reports whether the mesh is closed, because an open shell yields an unsigned field
that still repels at the surface: the fluid looks right while nothing can be pushed back out.
Determinism, measured
The sensor suite has claimed bit-exact replay for a while. This release audits that claim,
finds three real leaks, fixes them, and narrows what's left.
VulkanRenderer::setSimTime(double)(734887e9) pins every frame-path wall-clock read —TAA blend dt, shade animation time, DLSS/FSR deltas, ocean foam decay, grass/displacement
timestamps, the cloud clock — to an app-driven simulation clock. Unset, everything falls back
to the wall clock, byte-unchanged.
d94d96c9,4044fc45) —probe_updatewas thelast determinism leak. With it fixed, the whole pipeline replays bit-exactly: 5 fresh
processes × 120 frames, 6/6 streams identical, beauty pass included.
f8efa781,df55af8d). The audit's--idstabilityaxis caught 1760 pillar beams flipping label 3→2 across scene churn,deterministically — the label API never reached the returns. Returns now translate through the
stable-id table. This one silently corrupted any dataset that relied on
setObjectInstanceId.f8efa781) — found by the same audit.replay_audit(8ec401e3,d76c8653,4997bd5e) is the tool that proves it, withbisection instruments (
aov_audit,taasplit,dumprgb) that split the frame to localise adivergence.
ParticleFieldfed by GPU PhysX throughOwnership::Interopforfeits byte-reproducibility, because the bytes are authored on the otherside of the import. Gate those by tolerance, never by compare.
31d708a9).Ray-traced acoustics (#420)
AcousticScenetraces occlusion and transmission through scene geometry — entry and exit of asolid counts as one wall — and probes the room with bounced rays for an Eyring RT60 and openness
estimate. Sources get a low-pass occlusion splice (20 kHz → 600 Hz) and a shared Freeverb bus.
Needs no PhysX: the examples' BVH was promoted into the core library as
threepp/utils/BVH.hppwithraycast/raycastAny.Authorable per mesh in the editor via
AcousticSurfaceConfig, and live in the player (it ridesAudioPlaySession). Exampleexamples/audio/acousticswith a headless--selftest.Hardware-in-the-loop flight
A real, unmodified ArduCopter flies a threepp PhysX quad in lock-step (
19436aec) overArduPilot's JSON backend: one servo packet is exactly one PhysX substep, and threepp's timestamp
drives SITL's clock. Graduated from a demo into
extras/uav(3700a913) —SitlBridge,QuadSim,FrameConv,DroneVisual,MavlinkOut,ProximityScan,DownwashEffect. Documented indoc/ardupilot_sitl.md, which the README now links.2c5f040e,08663c0c) feeding ArduPilot's ownobstacle avoidance — BendyRuler flies the forest (
6e469306), and the autopilot proves itbrakes (
34ea8e75). It survives a SITL reboot.aa6c2dfband five follow-ups) — conserved rotor-wash dust that swallows thelanding, built on
ParticleField. The rangefinder flies through it.3ae4e12d); the demo survives the Vulkan deferred renderer.Editor
a9f61d69). Note the commit's ownstatus: phases 1–3 are verified (selftest 741 GL / 753 Vulkan,
PlayerCore_test,EditorParticleConfig_test); phase 4, the granular runtime, compiles and links but itssuites have not been run.
8b574779) — save any subtree as a document, add it back anywhere.47487776,9fc713fc,f4ef1c5a) —raise/lower/smooth/flatten brushes, and the sculpt layer is recovered as
currentHeights - base(configBefore)so a parameter edit re-lands it on the new base.Triangles and splat albedo bake into the document, so a saved scene renders and collides with
no editor present, and the collider cooks from the sculpted geometry (
b78c6369).76970c0c) — tick "Simulate as Character", pressPlay, WASD on a PhysX capsule controller. Clip roles are read from each clip's own root motion
rather than its name, because names are useless as a key across Mixamo packs.
.tpzscene archives (6dfeb43e,07928f06,c4c7f7c3,37bb30d9) — anuncompressed zip holding
scene.jsonbeside the assets its urls point at, textures kept as thebytes they arrived as, a linked
.glbriding inside once however many subtrees point at it.Measured on a 539-node scene: 220.8 MB → 112.5 MB, load 5.9 s → 1.3 s.
3b95b8b6), the inspector's undo ceremonywritten once (
5bf104a6), console lines wrap, hierarchy indent tightened, selftest cut intonamed sections that report a failing line (
9d2ce4dd).f363008c).Also new
BufferGeometryreadback (2ba72d74) —get_attribute(name)returns an(N, item_size)float32 array,get_index()a flat uint32 array (orNonefor a trianglesoup), and
attribute_names()lists what a geometry carries.set_attributehas had nocounterpart until now, which was fine while Python authored the vertices and a dead end the
moment a loader did: a model read by
ModelLoaderwas aBufferGeometryfull of triangleswith no way out, so anyone wanting them had to re-parse the file in Python, one format at a
time. Returns copies — the backing storage is reallocated by
set_attributeand freed bydispose, so a view would dangle. Typed in the stub asNDArray[float32] | Nonerather thanAny, which matters now thatpy.typedships.TrackballControls(e1450a7a) — full SO(3) rotation including roll, carryingcamera.upwith it, for objects with no meaningful up. Two deliberate departures from the three.js r129
original: it guards the degenerate axis when the drag runs parallel to the eye vector, and it
fixes orthographic pan (upstream scales by
eye.length(), meaningless for an ortho frustum).d8e21a29) —threepp::Flock, boids with perching, CPU-baked and asset-free. Exampleflock_demo.54419033) —PbdParticles,ChuteEmitter,GranularVisual.The belt carries the grains with no particle-specific coupling at all (
d80aa6b2), conveyorslearned bends (
d4662bd0), andgranular_conveyorscales to 300k with a--field pf|imA/Band a
--feed dtoddevice-to-device path.CameraSensorcaptures on Vulkan (89b7dbbf) — it used to return false on any backendwithout render targets; it now lazily attaches a persistent secondary view. Three consequences
the header spells out: one frame of latency, the first capture or two return false while it
warms up, and per-view visibility does not exist. New
renderSplatsmember (default true) —on an RTX 4070, one 256×192 secondary view: 60k splats 2.40 → 5.05 ms/frame, 500k splats
2.54 → 9.90.
Canvas::Parameters::position(x, y)plus runtimesetPosition()/position(), inouter-frame coordinates so a requested (0,0) doesn't tuck the title bar off-screen
(
f2997bdc). Exposed to Python. Wayland ignores the move; the protocol has no client-sidepositioning.
d8166d7b) — pendant shoots, drooping cards, dark twigs; and canopiesstopped being a hundred giant leaf balls (
6cc103f9).Performance
9d4220a3) — the newEntrySpan. At115.6k static instances: frustum cull 12.00 → 0.004 ms, motion matrices 11.31 → 0.006,
indirect build 3.83 → 0.026,
ensureSceneBuilt8.36 → 0.080. At 100k moving grains: emissivewalk 220.93 → 0.008 ms, motion 54.5 → 11.3, cull 43.1 → 5.6.
(This invalidates the 300k-grain table in
6ad7cb56— that was the pre-EntrySpanmeasurement, taken in the same release.)
2f7cedb2) — a hidden-windowvkQueuePresentKHRcost5.5–10.6 ms of host time per frame on Windows/NVIDIA, entirely outside
cpuFrameMsso nocounter had ever seen it. A headless canvas now pins one swapchain image per in-flight slot and
never presents.
2f22cd25).d8e21a29) — afterthree consecutive dirty frames, a record moves to staging upload + GPU copy + BLAS refit
recorded into the frame command buffer, replacing a device-wide drain plus two submit-and-wait
one-shots every frame. This matters to anyone doing CPU vertex animation — trails, ribbons,
procedural deformation — not just to birds.
af2be06a), using variance bothchannels already propagate. On an RTX 4070 ocean bench: 1.35 ms mean, against 2.15 at
global-4 and 1.29 at hard-2 — i.e. nearly the cost of two passes with the quality of four.
The default is 4 passes;
THREEPP_DENOISE_ATROUS_PASSES(2..4) stays for A/B.(An earlier commit in this range moved the default to 2; this one moved it back. The shipped
default is 4.)
9a66d68d).128bdd80) — opt-in,not on by default (
09aad227).db95d256) — one bone-matrix buffer, twoframes in flight.
0431a0f2) — 771 defshad zero
call_guardbefore this. Contract change worth knowing: calls that used to beserialised by the GIL now genuinely overlap. Measured by stamping wall-clock inside the stall
windows: 3.5M spinner iterations inside one 450 ms step.
spot_slamholds 60 fps, from 28.9 (52669732) — the scan stops waiting for the frame itjust drew (fire/deliver split, 31 ms → 2 ms), plus
render_scale 0.5with FSR.Fixes
70186d82) —directional, spot and point shadows put everything the light reached in shadow.
linear (
60610e28) — bloom's0x05050aarrived as (38, 38, 56).autothat should have beenauto&, sothe cache had never hit since the port (
f92dd252).9334df55) — at 1920×1200 thisread 109,440 bytes past the end of the buffer and died with an access violation, 3 runs out of
3. A mismatched readback is now an exception rather than a page fault.
b68bdf65).DISPLAYno longer kills a headless canvas (f2dccbfa) — a dead X server leavesDISPLAY=:1behind andglfwInitdied on it. Observed on Colab. Verified display-freeend-to-end on WSL lavapipe.
reset bugs silently stopped a field-only scene drawing after 64 frames.
69e322c1); 2× MSAA images clamp to a count thedevice actually has (
fd287af1);ProximityScanandtileVariantsnow build on GCC 11.2493e48a) — a configure-time warning and a clean runtime failureat device selection, plus two portability bugs fixed on the way.
Packaging
fixed (
c3ca41f0). Two defects compounding: vcpkg'svulkan-loaderport defaults its WSIfeatures OFF (upstream defaults them ON), so
vkCreateInstanceansweredVK_ERROR_EXTENSION_NOT_PRESENTfor the very extensions GLFW had just asked for; andauditwheel's renaming meant GLFW's
dlopen("libvulkan.so.1")missed the wheel's copy and loadedthe system loader instead — two loaders in one process. Now asserted in CI.
bcd4ee6e) — the-Osgate testedCMAKE_BUILD_TYPE, which is empty on every multi-config generator, and scikit-build-corehard-codes the Visual Studio generator for Windows wheels. The published Windows and Linux
wheels carried asymmetric shader binaries from the same source and version.
cuda_interop.pyandpy.typednow reach the wheel (842f7453) — so the Vulkan zero-copypath arrives complete in its first release rather than half-shipped, and PEP 561 type checkers
stop ignoring the stubs.
e12a91cc) — alinux-vulkan-lavapipejob on Mesa's softwarerasterizer under
THREEPP_VULKAN_VALIDATION=1+THREEPP_VULKAN_STRICT_VALIDATION=1, with apost-step that fails the job if any test skipped itself, so it cannot go green having rendered
nothing. It gates spec-cleanliness, not pixels — the filter is
^(VulkanValidation|VulkanCameraModel)_test$, and it runs unlit because lit shading segfaultsinside lavapipe's JIT. Golden images remain a local pre-push check.
Known gaps in this release
Stated rather than smoothed over:
threepp_player—FlockPlaySessionis notregistered in
PlayerCore.cpp, unlike the other ten. A document with a flock will not behavethe same in CI as in the editor.
a9f61d69).reflections-through-transmission; four more call sites, deliberately deferred.
ParticleField::TracedRepris stored and consumed by nothing._OPAQUE_FDpath is untested — no CI job has both Vulkan and CUDA.This discussion was created from the release 2026-08-21.
All reactions